@beechcms/api 0.5.0 → 0.6.0-preview.2
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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/Searching.svg +1 -0
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +43 -0
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +265 -238
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
- package/src/auth/bcrypt-hash-provider.ts +24 -20
- package/src/auth/constants.ts +14 -10
- package/src/auth/generate-refresh-token.test.ts +23 -19
- package/src/auth/hash-provider.test.ts +55 -46
- package/src/auth/jose-token-service.ts +62 -55
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +100 -92
- package/src/auth/login.ts +78 -74
- package/src/auth/refresh.ts +11 -5
- package/src/auth/token-service.test.ts +90 -82
- package/src/factory.ts +370 -347
- package/src/features/automations/__tests__/action-executors.test.ts +255 -268
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
- package/src/features/automations/action-executors/index.ts +37 -33
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
- package/src/features/automations/action-executors/webhook.executor.ts +67 -25
- package/src/features/automations/automation-runner.ts +85 -81
- package/src/features/automations/automation-runner.utils.ts +47 -43
- package/src/features/automations/automations.handler.ts +197 -193
- package/src/features/automations/automations.schema.ts +177 -160
- package/src/features/automations/context-resolver.ts +152 -148
- package/src/features/automations/cron-runner.ts +140 -136
- package/src/features/automations/cron-runner.utils.ts +44 -40
- package/src/features/automations/filter-translation.ts +46 -42
- package/src/features/automations/index.ts +16 -12
- package/src/features/automations/template-grammar.ts +245 -241
- package/src/features/automations/var-access-resolver.ts +140 -136
- package/src/features/automations/when-evaluator.ts +87 -83
- package/src/features/automations/when-pushdown.ts +57 -53
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +16 -10
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +129 -165
- package/src/features/content/handlers/delete.ts +62 -87
- package/src/features/content/handlers/facets.ts +49 -45
- package/src/features/content/handlers/get.ts +120 -116
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +174 -88
- package/src/features/content/handlers/update.ts +174 -219
- package/src/features/content/index.ts +26 -20
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -170
- package/src/features/draft/draft.middleware.ts +66 -62
- package/src/features/draft/index.ts +5 -1
- package/src/features/email/email.provider.ts +42 -38
- package/src/features/email/email.service.ts +98 -93
- package/src/features/email/email.types.ts +119 -108
- package/src/features/email/index.ts +33 -29
- package/src/features/email/providers/resend.ts +67 -63
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +19 -15
- package/src/features/email/templates/password-changed.ts +63 -59
- package/src/features/email/templates/password-reset.ts +68 -64
- package/src/features/email/templates/shell.ts +96 -92
- package/src/features/notifications/index.ts +5 -1
- package/src/features/notifications/notifications.handler.ts +105 -101
- package/src/features/password-reset/index.ts +19 -15
- package/src/features/password-reset/request.ts +92 -82
- package/src/features/password-reset/reset.ts +102 -92
- package/src/features/rotate-field/index.ts +5 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -128
- package/src/features/rotate-field/rotate-field.schema.ts +17 -13
- package/src/features/schema/schema.handler.ts +131 -16
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +1199 -0
- package/src/features/seeds/seeds.handler.ts +558 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +392 -302
- package/src/features/setup/index.ts +288 -91
- package/src/features/stats/index.ts +5 -1
- package/src/features/stats/stats.handler.ts +420 -430
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +65 -56
- package/src/media-utils.ts +82 -78
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -32
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -52
- package/src/middleware/rate-limit.middleware.ts +45 -41
- package/src/middleware/repository.middleware.ts +117 -91
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +25 -21
- package/src/middleware.ts +45 -47
- package/src/public/access-policy.ts +27 -23
- package/src/public/api-key-middleware.ts +57 -53
- package/src/public/cache-utils.ts +38 -34
- package/src/public/entry-projection.ts +46 -42
- package/src/public/idempotency.ts +23 -19
- package/src/public/index.ts +16 -12
- package/src/public/problem-details.ts +124 -53
- package/src/public/public-add.ts +114 -110
- package/src/public/public-edit.ts +163 -159
- package/src/public/public-errors.ts +19 -15
- package/src/public/public-read.ts +63 -59
- package/src/public/public-routes.ts +88 -84
- package/src/public/query-builder.test.ts +224 -220
- package/src/public/query-builder.ts +156 -152
- package/src/public/rate-limit-middleware.ts +34 -30
- package/src/public/read-list.ts +54 -50
- package/src/public/read-single.ts +48 -44
- package/src/public/response-builder.ts +30 -26
- package/src/public/sanitize.ts +69 -65
- package/src/public/slug-utils.ts +18 -14
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
- package/src/rate-limit/no-op-rate-limiter.ts +11 -7
- package/src/search-utils.test.ts +211 -207
- package/src/search-utils.ts +213 -209
- package/src/search.ts +65 -61
- package/src/shared/apply-policies.test.ts +81 -77
- package/src/shared/apply-policies.ts +67 -63
- package/src/shared/automations.repository.d1.ts +150 -146
- package/src/shared/background-notification-service.test.ts +62 -58
- package/src/shared/background-notification-service.ts +52 -48
- package/src/shared/base.repository.d1.ts +32 -28
- package/src/shared/content-utils.test.ts +165 -161
- package/src/shared/content-utils.ts +86 -82
- package/src/shared/content.repository.d1.test.ts +507 -312
- package/src/shared/content.repository.d1.ts +851 -382
- package/src/shared/d1-activity-log.repository.test.ts +140 -136
- package/src/shared/d1-activity-log.repository.ts +105 -101
- package/src/shared/d1-activity-logger.test.ts +86 -82
- package/src/shared/d1-activity-logger.ts +67 -63
- package/src/shared/d1-analytics.repository.test.ts +78 -74
- package/src/shared/d1-analytics.repository.ts +85 -81
- package/src/shared/d1-content-scan.repository.test.ts +80 -76
- package/src/shared/d1-content-scan.repository.ts +37 -29
- package/src/shared/d1-notification.repository.test.ts +128 -124
- package/src/shared/d1-notification.repository.ts +118 -114
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
- package/src/shared/d1-password-reset-token.repository.ts +56 -52
- package/src/shared/d1-search.repository.test.ts +87 -83
- package/src/shared/d1-search.repository.ts +88 -84
- package/src/shared/d1-session.repository.test.ts +125 -121
- package/src/shared/d1-session.repository.ts +102 -98
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +151 -147
- package/src/shared/d1-user.repository.ts +119 -109
- package/src/shared/d1-widget.repository.test.ts +267 -217
- package/src/shared/d1-widget.repository.ts +385 -337
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +13 -9
- package/src/shared/fixed-clock.ts +25 -21
- package/src/shared/fts-sync.ts +8 -4
- package/src/shared/idempotency.repository.d1.test.ts +83 -79
- package/src/shared/idempotency.repository.d1.ts +60 -56
- package/src/shared/in-memory-activity-logger.ts +19 -15
- package/src/shared/in-memory-notification-service.ts +19 -15
- package/src/shared/in-memory-seed.repository.ts +58 -0
- package/src/shared/media.repository.d1.test.ts +107 -103
- package/src/shared/media.repository.d1.ts +68 -64
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +141 -137
- package/src/shared/request-utils.ts +26 -22
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +33 -22
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +41 -40
- package/src/shared/storage/s3-bucket.ts +195 -163
- package/src/shared/storage-utils.ts +40 -36
- package/src/shared/system-stats.repository.d1.test.ts +58 -54
- package/src/shared/system-stats.repository.d1.ts +48 -44
- package/src/types.ts +90 -67
- package/src/upload.ts +198 -187
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -208
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- package/src/shared/storage/r2-binding-bucket.ts +0 -81
|
@@ -1,302 +1,392 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
return context.json({ success: true })
|
|
199
|
-
})
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*/
|
|
205
|
-
settingsApp.
|
|
206
|
-
const { sub: userId } = context.get('jwtPayload')
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
await context.get('
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
1
|
+
// SPDX-License-Identifier: BUSL-1.1
|
|
2
|
+
// Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
|
|
3
|
+
// See LICENSE in the repository root for license terms.
|
|
4
|
+
|
|
5
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
6
|
+
import { Hono } from 'hono'
|
|
7
|
+
import { sha256hex, type SiteSettings } from '@beechcms/core'
|
|
8
|
+
import type { Env, Variables } from '../../types'
|
|
9
|
+
|
|
10
|
+
const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
11
|
+
|
|
12
|
+
const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
13
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
14
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
15
|
+
const SESSION_LIST_LIMIT = 20
|
|
16
|
+
const ACTIVITY_LOG_LIMIT = 30
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* GET /api/settings
|
|
20
|
+
* Retrieves the general site configuration from the database.
|
|
21
|
+
*/
|
|
22
|
+
settingsApp.get('/', async (context) => {
|
|
23
|
+
const s = await context.get('siteSettingsRepository').getAll()
|
|
24
|
+
return context.json({
|
|
25
|
+
siteTitle: s.siteTitle,
|
|
26
|
+
siteLogo: '/beechLogoDark.svg',
|
|
27
|
+
defaultLanguage: s.defaultLanguage,
|
|
28
|
+
timezone: s.timezone,
|
|
29
|
+
currency: s.currency,
|
|
30
|
+
company: {
|
|
31
|
+
name: s.companyName,
|
|
32
|
+
website: s.companyWebsite,
|
|
33
|
+
abbreviation: s.companyAbbreviation,
|
|
34
|
+
},
|
|
35
|
+
dateFormat: context.env.DATE_FORMAT || 'DD-MM-YYYY',
|
|
36
|
+
features: {
|
|
37
|
+
drafts: true,
|
|
38
|
+
media: true,
|
|
39
|
+
search: true,
|
|
40
|
+
activityLog: true,
|
|
41
|
+
email: context.env.EMAIL_PROVIDER === 'smtp' || !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY),
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* PUT /api/settings
|
|
48
|
+
* Updates the general site configuration in the database.
|
|
49
|
+
*/
|
|
50
|
+
settingsApp.put('/', async (context) => {
|
|
51
|
+
let payload: Record<string, unknown>
|
|
52
|
+
try {
|
|
53
|
+
payload = await context.req.json()
|
|
54
|
+
} catch {
|
|
55
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const siteTitle = typeof payload.siteTitle === 'string' ? payload.siteTitle.trim() : undefined
|
|
59
|
+
const defaultLanguage = typeof payload.defaultLanguage === 'string' ? payload.defaultLanguage.trim() : undefined
|
|
60
|
+
const timezone = typeof payload.timezone === 'string' ? payload.timezone.trim() : undefined
|
|
61
|
+
const currency = typeof payload.currency === 'string' ? payload.currency.trim() : undefined
|
|
62
|
+
|
|
63
|
+
let companyName: string | undefined | null = undefined
|
|
64
|
+
let companyWebsite: string | undefined | null = undefined
|
|
65
|
+
let companyAbbreviation: string | undefined | null = undefined
|
|
66
|
+
|
|
67
|
+
if (payload.company !== undefined) {
|
|
68
|
+
if (payload.company === null) {
|
|
69
|
+
companyName = null
|
|
70
|
+
companyWebsite = null
|
|
71
|
+
companyAbbreviation = null
|
|
72
|
+
} else if (typeof payload.company === 'object') {
|
|
73
|
+
const company = payload.company as Record<string, unknown>
|
|
74
|
+
companyName = typeof company.name === 'string' ? company.name.trim() : (company.name === null ? null : undefined)
|
|
75
|
+
companyWebsite = typeof company.website === 'string' ? (company.website.trim() || null) : (company.website === null ? null : undefined)
|
|
76
|
+
companyAbbreviation = typeof company.abbreviation === 'string' ? (company.abbreviation.trim() || null) : (company.abbreviation === null ? null : undefined)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (defaultLanguage !== undefined && !['it', 'en'].includes(defaultLanguage)) {
|
|
81
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid default language (must be it or en)' }, 400)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (companyWebsite && companyWebsite !== '') {
|
|
85
|
+
try {
|
|
86
|
+
new URL(companyWebsite)
|
|
87
|
+
} catch {
|
|
88
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid company website URL' }, 400)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const fieldsToUpdate: Partial<SiteSettings> = {}
|
|
93
|
+
if (siteTitle !== undefined) fieldsToUpdate.siteTitle = siteTitle
|
|
94
|
+
if (defaultLanguage !== undefined) fieldsToUpdate.defaultLanguage = defaultLanguage
|
|
95
|
+
if (timezone !== undefined) fieldsToUpdate.timezone = timezone
|
|
96
|
+
if (currency !== undefined) fieldsToUpdate.currency = currency
|
|
97
|
+
if (companyName !== undefined) fieldsToUpdate.companyName = companyName
|
|
98
|
+
if (companyWebsite !== undefined) fieldsToUpdate.companyWebsite = companyWebsite
|
|
99
|
+
if (companyAbbreviation !== undefined) fieldsToUpdate.companyAbbreviation = companyAbbreviation
|
|
100
|
+
|
|
101
|
+
// If companyName is updated and siteTitle isn't specified, sync siteTitle
|
|
102
|
+
if (companyName && siteTitle === undefined) {
|
|
103
|
+
fieldsToUpdate.siteTitle = companyName
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await context.get('siteSettingsRepository').setMany(fieldsToUpdate)
|
|
107
|
+
return context.json({ success: true })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* GET /api/settings/me
|
|
112
|
+
* Retrieves the currently authenticated user's profile and preferences.
|
|
113
|
+
*/
|
|
114
|
+
settingsApp.get('/me', async (context) => {
|
|
115
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
116
|
+
|
|
117
|
+
const currentUser = await context.get('userRepository').findById(userId)
|
|
118
|
+
if (!currentUser) {
|
|
119
|
+
return context.json({ error: 'User not found' }, 404)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let notificationPreferences: Record<string, boolean>
|
|
123
|
+
try {
|
|
124
|
+
notificationPreferences = JSON.parse(currentUser.notificationPreferences || '{}')
|
|
125
|
+
} catch {
|
|
126
|
+
notificationPreferences = {}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let avatarUrl = currentUser.avatarUrl
|
|
130
|
+
if (!avatarUrl && currentUser.email) {
|
|
131
|
+
const emailHash = await sha256hex(currentUser.email.trim().toLowerCase())
|
|
132
|
+
avatarUrl = `https://gravatar.com/avatar/${emailHash}?d=mp`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return context.json({
|
|
136
|
+
id: currentUser.id,
|
|
137
|
+
email: currentUser.email,
|
|
138
|
+
name: currentUser.name,
|
|
139
|
+
surname: currentUser.surname,
|
|
140
|
+
avatarUrl,
|
|
141
|
+
notificationPrefs: {
|
|
142
|
+
contentCreate: notificationPreferences.contentCreate ?? true,
|
|
143
|
+
contentUpdate: notificationPreferences.contentUpdate ?? true,
|
|
144
|
+
contentDelete: notificationPreferences.contentDelete ?? true,
|
|
145
|
+
mediaUpload: notificationPreferences.mediaUpload ?? false,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* PUT /api/settings/profile
|
|
152
|
+
* Updates the user's name and email address.
|
|
153
|
+
*/
|
|
154
|
+
settingsApp.put('/profile', async (context) => {
|
|
155
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
156
|
+
|
|
157
|
+
let payload: Record<string, unknown>
|
|
158
|
+
try {
|
|
159
|
+
payload = await context.req.json()
|
|
160
|
+
} catch {
|
|
161
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const nameInput = typeof payload.name === 'string' ? payload.name.trim() : null
|
|
165
|
+
const surnameInput = typeof payload.surname === 'string' ? payload.surname.trim() : null
|
|
166
|
+
const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
|
|
167
|
+
|
|
168
|
+
if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
|
|
169
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid email format' }, 400)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (nameInput !== null && nameInput.length > 100) {
|
|
173
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (surnameInput !== null && surnameInput.length > 100) {
|
|
177
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Surname is too long (maximum 100 characters)' }, 400)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hasNoFields = nameInput === null && surnameInput === null && emailInput === null
|
|
181
|
+
if (hasNoFields) {
|
|
182
|
+
return context.json({ error: 'No fields to update' }, 400)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (emailInput !== null) {
|
|
186
|
+
const emailTaken = await context.get('userRepository').emailBelongsToAnotherUser(emailInput, userId)
|
|
187
|
+
if (emailTaken) {
|
|
188
|
+
return context.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email address is already in use' }, 409)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const fieldsToUpdate: { name?: string; surname?: string; email?: string } = {}
|
|
193
|
+
if (nameInput !== null) fieldsToUpdate.name = nameInput
|
|
194
|
+
if (surnameInput !== null) fieldsToUpdate.surname = surnameInput
|
|
195
|
+
if (emailInput !== null) fieldsToUpdate.email = emailInput
|
|
196
|
+
|
|
197
|
+
await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
|
|
198
|
+
return context.json({ success: true })
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* PUT /api/settings/password
|
|
203
|
+
* Updates the user's password after verifying the current one.
|
|
204
|
+
*/
|
|
205
|
+
settingsApp.put('/password', async (context) => {
|
|
206
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
207
|
+
|
|
208
|
+
let payload: Record<string, unknown>
|
|
209
|
+
try {
|
|
210
|
+
payload = await context.req.json()
|
|
211
|
+
} catch {
|
|
212
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : ''
|
|
216
|
+
const newPassword = typeof payload.newPassword === 'string' ? payload.newPassword : ''
|
|
217
|
+
|
|
218
|
+
if (!currentPassword || !newPassword) {
|
|
219
|
+
return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
|
|
220
|
+
}
|
|
221
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
222
|
+
return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
|
|
223
|
+
}
|
|
224
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
225
|
+
return context.json({ error: 'Password is too long' }, 400)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const userRecord = await context.get('userRepository').findById(userId)
|
|
229
|
+
if (!userRecord) {
|
|
230
|
+
return context.json({ error: 'User not found' }, 404)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hashProvider = context.get('hashProvider')
|
|
234
|
+
const isPasswordCorrect = await hashProvider.verify(currentPassword, userRecord.passwordHash)
|
|
235
|
+
if (!isPasswordCorrect) {
|
|
236
|
+
return context.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Current password is incorrect' }, 401)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const hashedNewPassword = await hashProvider.hash(newPassword)
|
|
240
|
+
await context.get('userRepository').updatePasswordHash(userId, hashedNewPassword)
|
|
241
|
+
return context.json({ success: true })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* PUT /api/settings/avatar
|
|
246
|
+
* Updates the user's avatar URL.
|
|
247
|
+
*/
|
|
248
|
+
settingsApp.put('/avatar', async (context) => {
|
|
249
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
250
|
+
|
|
251
|
+
let payload: Record<string, unknown>
|
|
252
|
+
try {
|
|
253
|
+
payload = await context.req.json()
|
|
254
|
+
} catch {
|
|
255
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const avatarUrl = typeof payload.avatarUrl === 'string' ? payload.avatarUrl.trim() : null
|
|
259
|
+
await context.get('userRepository').updateAvatarUrl(userId, avatarUrl)
|
|
260
|
+
return context.json({ success: true })
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* GET /api/settings/sessions
|
|
265
|
+
* Retrieves a list of active refresh tokens for the user.
|
|
266
|
+
*/
|
|
267
|
+
settingsApp.get('/sessions', async (context) => {
|
|
268
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
269
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
270
|
+
const sessions = await context.get('sessionRepository').listActiveForUser(userId, nowTimestamp, SESSION_LIST_LIMIT)
|
|
271
|
+
return context.json(sessions)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* DELETE /api/settings/sessions/:id
|
|
276
|
+
* Revokes a specific refresh token (session).
|
|
277
|
+
*/
|
|
278
|
+
settingsApp.delete('/sessions/:id', async (context) => {
|
|
279
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
280
|
+
const sessionId = context.req.param('id')
|
|
281
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
282
|
+
|
|
283
|
+
const wasRevoked = await context.get('sessionRepository').revokeById(sessionId, userId, nowTimestamp)
|
|
284
|
+
if (!wasRevoked) {
|
|
285
|
+
return context.json({ error: 'Session not found or already revoked' }, 404)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return context.json({ success: true })
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* GET /api/settings/activity
|
|
293
|
+
* Retrieves the latest activity logs for the user.
|
|
294
|
+
*/
|
|
295
|
+
settingsApp.get('/activity', async (context) => {
|
|
296
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
297
|
+
|
|
298
|
+
const entries = await context.get('activityLogRepository').list({
|
|
299
|
+
userId,
|
|
300
|
+
limit: ACTIVITY_LOG_LIMIT,
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
// Preserve legacy snake_case shape consumed by the dashboard activity tab.
|
|
304
|
+
const responseEntries = entries.map((entry) => ({
|
|
305
|
+
id: entry.id,
|
|
306
|
+
action: entry.action,
|
|
307
|
+
entity_type: entry.entityType,
|
|
308
|
+
entity_slug: entry.entitySlug,
|
|
309
|
+
details: entry.details ? JSON.stringify(entry.details) : null,
|
|
310
|
+
created_at: entry.createdAt,
|
|
311
|
+
}))
|
|
312
|
+
|
|
313
|
+
return context.json(responseEntries)
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* GET /api/settings/storage
|
|
318
|
+
* Calculates storage usage and identifies orphaned media files.
|
|
319
|
+
*/
|
|
320
|
+
settingsApp.get('/storage', async (context) => {
|
|
321
|
+
const mediaRepo = context.get('mediaRepository')
|
|
322
|
+
const statsRepo = context.get('systemStatsRepository')
|
|
323
|
+
|
|
324
|
+
const totalStorageUsedBytes = await statsRepo.getStorageUsage()
|
|
325
|
+
const totalFileCount = await mediaRepo.count()
|
|
326
|
+
|
|
327
|
+
const registeredSeeds = context.get('seedRegistry').all()
|
|
328
|
+
const referencedMediaKeys = await context.get('contentScanRepository').getReferencedMediaKeys(registeredSeeds)
|
|
329
|
+
|
|
330
|
+
const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
|
|
331
|
+
const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
|
|
332
|
+
|
|
333
|
+
return context.json({
|
|
334
|
+
totalBytes: totalStorageUsedBytes,
|
|
335
|
+
fileCount: totalFileCount,
|
|
336
|
+
orphans: orphanedMediaFiles,
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* GET /api/settings/notifications
|
|
342
|
+
* Retrieves the user's notification preferences.
|
|
343
|
+
*/
|
|
344
|
+
settingsApp.get('/notifications', async (context) => {
|
|
345
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
346
|
+
|
|
347
|
+
const userRecord = await context.get('userRepository').findById(userId)
|
|
348
|
+
if (!userRecord) {
|
|
349
|
+
return context.json({ error: 'User not found' }, 404)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
let userPreferences: Record<string, boolean>
|
|
353
|
+
try {
|
|
354
|
+
userPreferences = JSON.parse(userRecord.notificationPreferences || '{}')
|
|
355
|
+
} catch {
|
|
356
|
+
userPreferences = {}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return context.json({
|
|
360
|
+
contentCreate: userPreferences.contentCreate ?? true,
|
|
361
|
+
contentUpdate: userPreferences.contentUpdate ?? true,
|
|
362
|
+
contentDelete: userPreferences.contentDelete ?? true,
|
|
363
|
+
mediaUpload: userPreferences.mediaUpload ?? false,
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* PUT /api/settings/notifications
|
|
369
|
+
* Updates the user's notification preferences.
|
|
370
|
+
*/
|
|
371
|
+
settingsApp.put('/notifications', async (context) => {
|
|
372
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
373
|
+
|
|
374
|
+
let payload: Record<string, unknown>
|
|
375
|
+
try {
|
|
376
|
+
payload = await context.req.json()
|
|
377
|
+
} catch {
|
|
378
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const newPreferences = {
|
|
382
|
+
contentCreate: payload.contentCreate === true,
|
|
383
|
+
contentUpdate: payload.contentUpdate === true,
|
|
384
|
+
contentDelete: payload.contentDelete === true,
|
|
385
|
+
mediaUpload: payload.mediaUpload === true,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
await context.get('userRepository').updateNotificationPreferences(userId, JSON.stringify(newPreferences))
|
|
389
|
+
return context.json({ success: true })
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
export { settingsApp }
|