@beechcms/api 0.6.0-preview.1 → 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.
Files changed (222) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  4. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  5. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  6. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  7. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  8. package/assets/dashboard/beechLogoDark.svg +48 -48
  9. package/assets/dashboard/index.html +18 -18
  10. package/assets/dashboard/noResult.svg +42 -42
  11. package/assets/dashboard/sol.svg +3 -3
  12. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  13. package/migrations/0000_v040_base.sql +265 -265
  14. package/migrations/0029_automations.sql +14 -14
  15. package/migrations/0030_test_seeds.sql +125 -125
  16. package/package.json +2 -2
  17. package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
  18. package/src/auth/__fixtures__/static-token-service.ts +22 -22
  19. package/src/auth/bcrypt-hash-provider.ts +24 -24
  20. package/src/auth/constants.ts +14 -14
  21. package/src/auth/generate-refresh-token.test.ts +23 -23
  22. package/src/auth/hash-provider.test.ts +55 -55
  23. package/src/auth/jose-token-service.ts +62 -62
  24. package/src/auth/jwt-claims-passthrough.test.ts +87 -87
  25. package/src/auth/login.test.ts +100 -100
  26. package/src/auth/login.ts +78 -78
  27. package/src/auth/refresh.ts +11 -11
  28. package/src/auth/token-service.test.ts +90 -90
  29. package/src/factory.ts +370 -368
  30. package/src/features/automations/__tests__/action-executors.test.ts +255 -255
  31. package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
  32. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
  33. package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
  34. package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
  35. package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
  36. package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
  37. package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
  38. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
  39. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
  40. package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
  41. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
  42. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
  43. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
  44. package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
  45. package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
  46. package/src/features/automations/action-executors/index.ts +37 -37
  47. package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
  48. package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
  49. package/src/features/automations/action-executors/webhook.executor.ts +67 -67
  50. package/src/features/automations/automation-runner.ts +85 -85
  51. package/src/features/automations/automation-runner.utils.ts +47 -47
  52. package/src/features/automations/automations.handler.ts +197 -197
  53. package/src/features/automations/automations.schema.ts +177 -177
  54. package/src/features/automations/context-resolver.ts +152 -152
  55. package/src/features/automations/cron-runner.ts +140 -140
  56. package/src/features/automations/cron-runner.utils.ts +44 -44
  57. package/src/features/automations/filter-translation.ts +46 -46
  58. package/src/features/automations/index.ts +16 -16
  59. package/src/features/automations/template-grammar.ts +245 -245
  60. package/src/features/automations/var-access-resolver.ts +140 -140
  61. package/src/features/automations/when-evaluator.ts +87 -87
  62. package/src/features/automations/when-pushdown.ts +57 -57
  63. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
  64. package/src/features/backrefs/backrefs.handler.ts +168 -168
  65. package/src/features/backrefs/d1-backref.repository.ts +84 -84
  66. package/src/features/backrefs/index.ts +5 -5
  67. package/src/features/content/constants.ts +16 -16
  68. package/src/features/content/handlers/bulk.handler.ts +185 -185
  69. package/src/features/content/handlers/create.ts +129 -129
  70. package/src/features/content/handlers/delete.ts +62 -62
  71. package/src/features/content/handlers/facets.ts +49 -49
  72. package/src/features/content/handlers/get.ts +120 -120
  73. package/src/features/content/handlers/helpers.test.ts +180 -180
  74. package/src/features/content/handlers/helpers.ts +93 -93
  75. package/src/features/content/handlers/list.ts +174 -174
  76. package/src/features/content/handlers/update.ts +174 -174
  77. package/src/features/content/index.ts +26 -26
  78. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  79. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  80. package/src/features/dashboard-layout/index.ts +5 -0
  81. package/src/features/draft/draft.handler.ts +195 -195
  82. package/src/features/draft/draft.middleware.ts +66 -66
  83. package/src/features/draft/index.ts +5 -5
  84. package/src/features/email/email.provider.ts +42 -42
  85. package/src/features/email/email.service.ts +98 -98
  86. package/src/features/email/email.types.ts +119 -119
  87. package/src/features/email/index.ts +33 -33
  88. package/src/features/email/providers/resend.ts +67 -67
  89. package/src/features/email/providers/smtp.ts +55 -55
  90. package/src/features/email/templates/automation-mail.ts +19 -19
  91. package/src/features/email/templates/password-changed.ts +63 -63
  92. package/src/features/email/templates/password-reset.ts +68 -68
  93. package/src/features/email/templates/shell.ts +96 -96
  94. package/src/features/notifications/index.ts +5 -5
  95. package/src/features/notifications/notifications.handler.ts +105 -105
  96. package/src/features/password-reset/index.ts +19 -19
  97. package/src/features/password-reset/request.ts +92 -92
  98. package/src/features/password-reset/reset.ts +102 -102
  99. package/src/features/rotate-field/index.ts +5 -5
  100. package/src/features/rotate-field/rotate-field.handler.ts +132 -132
  101. package/src/features/rotate-field/rotate-field.schema.ts +17 -17
  102. package/src/features/schema/schema.handler.ts +131 -131
  103. package/src/features/seeds/index.ts +5 -5
  104. package/src/features/seeds/seeds.handler.test.ts +1199 -632
  105. package/src/features/seeds/seeds.handler.ts +558 -693
  106. package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
  107. package/src/features/settings/settings.handler.ts +392 -392
  108. package/src/features/setup/index.ts +288 -204
  109. package/src/features/stats/index.ts +5 -5
  110. package/src/features/stats/stats.handler.ts +420 -420
  111. package/src/features/webhooks/index.ts +63 -63
  112. package/src/index.ts +65 -65
  113. package/src/media-utils.ts +82 -82
  114. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  115. package/src/middleware/auth-providers.middleware.ts +45 -45
  116. package/src/middleware/observability.middleware.test.ts +93 -0
  117. package/src/middleware/observability.middleware.ts +70 -70
  118. package/src/middleware/rate-limit.middleware.ts +45 -45
  119. package/src/middleware/repository.middleware.ts +117 -114
  120. package/src/middleware/seed-registry.middleware.test.ts +97 -97
  121. package/src/middleware/seed-registry.middleware.ts +21 -21
  122. package/src/middleware/storage.middleware.ts +25 -25
  123. package/src/middleware.ts +45 -45
  124. package/src/public/access-policy.ts +27 -27
  125. package/src/public/api-key-middleware.ts +57 -57
  126. package/src/public/cache-utils.ts +38 -38
  127. package/src/public/entry-projection.ts +46 -46
  128. package/src/public/idempotency.ts +23 -23
  129. package/src/public/index.ts +16 -16
  130. package/src/public/problem-details.ts +124 -124
  131. package/src/public/public-add.ts +114 -114
  132. package/src/public/public-edit.ts +163 -163
  133. package/src/public/public-errors.ts +19 -19
  134. package/src/public/public-read.ts +63 -63
  135. package/src/public/public-routes.ts +88 -88
  136. package/src/public/query-builder.test.ts +224 -224
  137. package/src/public/query-builder.ts +156 -156
  138. package/src/public/rate-limit-middleware.ts +34 -34
  139. package/src/public/read-list.ts +54 -54
  140. package/src/public/read-single.ts +48 -48
  141. package/src/public/response-builder.ts +30 -30
  142. package/src/public/sanitize.ts +69 -69
  143. package/src/public/slug-utils.ts +18 -18
  144. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
  145. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
  146. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
  147. package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
  148. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
  149. package/src/rate-limit/no-op-rate-limiter.ts +11 -11
  150. package/src/search-utils.test.ts +211 -211
  151. package/src/search-utils.ts +213 -213
  152. package/src/search.ts +65 -65
  153. package/src/shared/apply-policies.test.ts +81 -81
  154. package/src/shared/apply-policies.ts +67 -67
  155. package/src/shared/automations.repository.d1.ts +150 -150
  156. package/src/shared/background-notification-service.test.ts +62 -62
  157. package/src/shared/background-notification-service.ts +52 -52
  158. package/src/shared/base.repository.d1.ts +32 -32
  159. package/src/shared/content-utils.test.ts +165 -165
  160. package/src/shared/content-utils.ts +86 -86
  161. package/src/shared/content.repository.d1.test.ts +507 -458
  162. package/src/shared/content.repository.d1.ts +851 -851
  163. package/src/shared/d1-activity-log.repository.test.ts +140 -140
  164. package/src/shared/d1-activity-log.repository.ts +105 -105
  165. package/src/shared/d1-activity-logger.test.ts +86 -86
  166. package/src/shared/d1-activity-logger.ts +67 -67
  167. package/src/shared/d1-analytics.repository.test.ts +78 -78
  168. package/src/shared/d1-analytics.repository.ts +85 -85
  169. package/src/shared/d1-content-scan.repository.test.ts +80 -80
  170. package/src/shared/d1-content-scan.repository.ts +37 -37
  171. package/src/shared/d1-notification.repository.test.ts +128 -128
  172. package/src/shared/d1-notification.repository.ts +118 -118
  173. package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
  174. package/src/shared/d1-password-reset-token.repository.ts +56 -56
  175. package/src/shared/d1-search.repository.test.ts +87 -87
  176. package/src/shared/d1-search.repository.ts +88 -88
  177. package/src/shared/d1-session.repository.test.ts +125 -125
  178. package/src/shared/d1-session.repository.ts +102 -102
  179. package/src/shared/d1-setup-checklist.repository.ts +36 -36
  180. package/src/shared/d1-user.repository.test.ts +151 -151
  181. package/src/shared/d1-user.repository.ts +119 -119
  182. package/src/shared/d1-widget.repository.test.ts +267 -221
  183. package/src/shared/d1-widget.repository.ts +385 -341
  184. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  185. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  186. package/src/shared/demo-data-sql.ts +216 -54
  187. package/src/shared/demo-data.repository.d1.ts +20 -20
  188. package/src/shared/execution-context-scheduler.ts +13 -13
  189. package/src/shared/fixed-clock.ts +25 -25
  190. package/src/shared/fts-sync.ts +8 -8
  191. package/src/shared/idempotency.repository.d1.test.ts +83 -83
  192. package/src/shared/idempotency.repository.d1.ts +60 -60
  193. package/src/shared/in-memory-activity-logger.ts +19 -19
  194. package/src/shared/in-memory-notification-service.ts +19 -19
  195. package/src/shared/in-memory-seed.repository.ts +58 -51
  196. package/src/shared/media.repository.d1.test.ts +107 -107
  197. package/src/shared/media.repository.d1.ts +68 -68
  198. package/src/shared/qstash-notification-service.test.ts +67 -67
  199. package/src/shared/qstash-notification-service.ts +55 -55
  200. package/src/shared/query-utils.ts +141 -141
  201. package/src/shared/request-utils.ts +26 -26
  202. package/src/shared/schema-mutator.d1.test.ts +142 -0
  203. package/src/shared/schema-mutator.d1.ts +64 -64
  204. package/src/shared/seed-layout.repository.d1.test.ts +114 -114
  205. package/src/shared/seed-layout.repository.d1.ts +62 -62
  206. package/src/shared/seed-registry-cache.test.ts +68 -68
  207. package/src/shared/seed-registry-cache.ts +49 -49
  208. package/src/shared/seed.repository.d1.test.ts +143 -143
  209. package/src/shared/seed.repository.d1.ts +98 -98
  210. package/src/shared/sequential-id-generator.ts +33 -33
  211. package/src/shared/site-settings.repository.d1.ts +53 -53
  212. package/src/shared/storage/factory.ts +41 -41
  213. package/src/shared/storage/s3-bucket.ts +195 -195
  214. package/src/shared/storage-utils.ts +40 -40
  215. package/src/shared/system-stats.repository.d1.test.ts +58 -58
  216. package/src/shared/system-stats.repository.d1.ts +48 -48
  217. package/src/types.ts +90 -87
  218. package/src/upload.ts +198 -198
  219. package/src/widget.test.ts +85 -0
  220. package/src/widget.ts +251 -212
  221. package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
  222. package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
@@ -1,392 +1,392 @@
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 }
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 }