@beechcms/api 0.4.0-preview.9 → 0.4.1

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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
@@ -1,16 +1,16 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
-
5
- const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
-
7
- /**
8
- * Ritorna l'intero schema del CMS (la lista dei Seed configurati).
9
- * Usato dalla Dashboard per generare dinamicamente il menu e le form.
10
- */
11
- schemaApp.get('/', async (c) => {
12
- const registry = c.get('seedRegistry')
13
- return c.json(Object.values(registry))
14
- })
15
-
16
- export { schemaApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+
5
+ const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
+
7
+ /**
8
+ * Ritorna l'intero schema del CMS (la lista dei Seed configurati).
9
+ * Usato dalla Dashboard per generare dinamicamente il menu e le form.
10
+ */
11
+ schemaApp.get('/', async (context) => {
12
+ const registry = context.get('seedRegistry')
13
+ return context.json(registry.all())
14
+ })
15
+
16
+ export { schemaApp }
@@ -1,249 +1,301 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
- import type { Env, Variables } from '../../types'
5
-
6
- const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
7
-
8
- const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
- const MIN_PASSWORD_LENGTH = 8
10
- const MAX_PASSWORD_LENGTH = 128
11
- const BCRYPT_ROUNDS = 10
12
-
13
- type UserRow = {
14
- id: string
15
- email: string
16
- name: string | null
17
- avatar_url: string | null
18
- password_hash: string
19
- notification_prefs: string
20
- }
21
-
22
- type SessionRow = {
23
- id: string
24
- created_at: number
25
- expires_at: number
26
- }
27
-
28
- type ActivityRow = {
29
- id: string
30
- action: string
31
- entity_type: string
32
- entity_slug: string | null
33
- details: string | null
34
- created_at: number
35
- }
36
-
37
- type OrphanRow = {
38
- key: string
39
- filename: string
40
- mime_type: string
41
- size_bytes: number
42
- created_at: number
43
- }
44
-
45
-
46
- // GET /api/settings/me
47
- settingsApp.get('/me', async (c) => {
48
- const { sub } = c.get('jwtPayload')
49
- const user = await c.env.DB.prepare(
50
- 'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
51
- ).bind(sub).first<Omit<UserRow, 'password_hash'>>()
52
- if (!user) return c.json({ error: 'User not found' }, 404)
53
-
54
- let notificationPrefs: Record<string, boolean>
55
- try { notificationPrefs = JSON.parse(user.notification_prefs || '{}') } catch { notificationPrefs = {} }
56
-
57
- return c.json({
58
- id: user.id,
59
- email: user.email,
60
- name: user.name,
61
- avatarUrl: user.avatar_url,
62
- notificationPrefs: {
63
- contentCreate: notificationPrefs.contentCreate ?? true,
64
- contentUpdate: notificationPrefs.contentUpdate ?? true,
65
- contentDelete: notificationPrefs.contentDelete ?? true,
66
- mediaUpload: notificationPrefs.mediaUpload ?? false,
67
- },
68
- })
69
- })
70
-
71
- // PUT /api/settings/profile
72
- settingsApp.put('/profile', async (c) => {
73
- const { sub } = c.get('jwtPayload')
74
- let body: Record<string, unknown>
75
- try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
76
-
77
- const name = typeof body.name === 'string' ? body.name.trim() : null
78
- const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : null
79
-
80
- if (email !== null && !EMAIL_REGEX.test(email)) {
81
- return c.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Formato email non valido' }, 400)
82
- }
83
- if (name !== null && name.length > 100) {
84
- return c.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Nome troppo lungo (max 100 caratteri)' }, 400)
85
- }
86
-
87
- const updates: string[] = []
88
- const values: unknown[] = []
89
- if (name !== null) { updates.push('name = ?'); values.push(name) }
90
- if (email !== null) { updates.push('email = ?'); values.push(email) }
91
- if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400)
92
-
93
- if (email !== null) {
94
- const existing = await c.env.DB.prepare(
95
- 'SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1'
96
- ).bind(email, sub).first()
97
- if (existing) return c.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email già in uso' }, 409)
98
- }
99
-
100
- values.push(sub)
101
- await c.env.DB.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).bind(...values).run()
102
- return c.json({ success: true })
103
- })
104
-
105
- // PUT /api/settings/password
106
- settingsApp.put('/password', async (c) => {
107
- const { sub } = c.get('jwtPayload')
108
- let body: Record<string, unknown>
109
- try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
110
-
111
- const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : ''
112
- const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
113
-
114
- if (!currentPassword || !newPassword) return c.json({ error: 'currentPassword e newPassword obbligatori' }, 400)
115
- if (newPassword.length < MIN_PASSWORD_LENGTH) return c.json({ error: `La password deve essere di almeno ${MIN_PASSWORD_LENGTH} caratteri` }, 400)
116
- if (newPassword.length > MAX_PASSWORD_LENGTH) return c.json({ error: 'Password troppo lunga' }, 400)
117
-
118
- const user = await c.env.DB.prepare('SELECT password_hash FROM users WHERE id = ? LIMIT 1').bind(sub).first<{ password_hash: string }>()
119
- if (!user) return c.json({ error: 'User not found' }, 404)
120
-
121
- const valid = await bcrypt.compare(currentPassword, user.password_hash)
122
- if (!valid) return c.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Password attuale non corretta' }, 401)
123
-
124
- const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
125
- await c.env.DB.prepare('UPDATE users SET password_hash = ? WHERE id = ?').bind(newHash, sub).run()
126
- return c.json({ success: true })
127
- })
128
-
129
- // PUT /api/settings/avatar
130
- settingsApp.put('/avatar', async (c) => {
131
- const { sub } = c.get('jwtPayload')
132
- let body: Record<string, unknown>
133
- try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
134
-
135
- const avatarUrl = typeof body.avatarUrl === 'string' ? body.avatarUrl.trim() : null
136
- await c.env.DB.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').bind(avatarUrl, sub).run()
137
- return c.json({ success: true })
138
- })
139
-
140
- // GET /api/settings/sessions
141
- settingsApp.get('/sessions', async (c) => {
142
- const { sub } = c.get('jwtPayload')
143
- const now = Math.floor(Date.now() / 1000)
144
- const result = await c.env.DB.prepare(
145
- `SELECT id, created_at, expires_at FROM refresh_tokens
146
- WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
147
- ORDER BY created_at DESC LIMIT 20`
148
- ).bind(sub, now).all<SessionRow>()
149
- return c.json(result.results ?? [])
150
- })
151
-
152
- // DELETE /api/settings/sessions/:id
153
- settingsApp.delete('/sessions/:id', async (c) => {
154
- const { sub } = c.get('jwtPayload')
155
- const sessionId = c.req.param('id')
156
- const now = Math.floor(Date.now() / 1000)
157
- const result = await c.env.DB.prepare(
158
- `UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
159
- ).bind(now, sessionId, sub).run()
160
- const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
161
- if (changes === 0) return c.json({ error: 'Sessione non trovata o già revocata' }, 404)
162
- return c.json({ success: true })
163
- })
164
-
165
- // GET /api/settings/activity
166
- settingsApp.get('/activity', async (c) => {
167
- const { sub } = c.get('jwtPayload')
168
- const result = await c.env.DB.prepare(
169
- `SELECT id, action, entity_type, entity_slug, details, created_at
170
- FROM activity_logs WHERE user_id = ?
171
- ORDER BY created_at DESC LIMIT 30`
172
- ).bind(sub).all<ActivityRow>()
173
- return c.json(result.results ?? [])
174
- })
175
-
176
- // GET /api/settings/storage
177
- settingsApp.get('/storage', async (c) => {
178
- const stats = await c.env.DB.prepare(
179
- 'SELECT COUNT(*) as file_count, COALESCE(SUM(size_bytes), 0) as total_bytes FROM media_objects'
180
- ).first<{ file_count: number; total_bytes: number }>()
181
-
182
- // Collect all media keys referenced in file-type columns across all seeds
183
- const referencedKeys = new Set<string>()
184
- const seeds = Object.values(c.get('seedRegistry'))
185
- for (const seed of seeds) {
186
- const fileBranches = seed.branches.filter(b => b.type === 'file')
187
- if (fileBranches.length === 0) continue
188
- const cols = fileBranches.map(b => b.alias).join(', ')
189
- const rows = await c.env.DB.prepare(
190
- `SELECT ${cols} FROM content_${seed.slug}`
191
- ).all<Record<string, string | null>>()
192
- for (const row of rows.results ?? []) {
193
- const combined = Object.values(row).filter(Boolean).join(' ')
194
- for (const match of combined.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
195
- referencedKeys.add(decodeURIComponent(match[1]))
196
- }
197
- }
198
- }
199
-
200
- const allMediaRows = await c.env.DB.prepare(
201
- 'SELECT key, filename, mime_type, size_bytes, created_at FROM media_objects ORDER BY created_at DESC LIMIT 50'
202
- ).all<OrphanRow>()
203
- const orphanResults = (allMediaRows.results ?? []).filter(m => !referencedKeys.has(m.key))
204
- const orphans = { results: orphanResults }
205
-
206
- return c.json({
207
- totalBytes: stats?.total_bytes ?? 0,
208
- fileCount: stats?.file_count ?? 0,
209
- orphans: orphans.results ?? [],
210
- })
211
- })
212
-
213
- // GET /api/settings/notifications
214
- settingsApp.get('/notifications', async (c) => {
215
- const { sub } = c.get('jwtPayload')
216
- const user = await c.env.DB.prepare(
217
- 'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
218
- ).bind(sub).first<{ notification_prefs: string }>()
219
- if (!user) return c.json({ error: 'User not found' }, 404)
220
-
221
- let prefs: Record<string, boolean>
222
- try { prefs = JSON.parse(user.notification_prefs || '{}') } catch { prefs = {} }
223
-
224
- return c.json({
225
- contentCreate: prefs.contentCreate ?? true,
226
- contentUpdate: prefs.contentUpdate ?? true,
227
- contentDelete: prefs.contentDelete ?? true,
228
- mediaUpload: prefs.mediaUpload ?? false,
229
- })
230
- })
231
-
232
- // PUT /api/settings/notifications
233
- settingsApp.put('/notifications', async (c) => {
234
- const { sub } = c.get('jwtPayload')
235
- let body: Record<string, unknown>
236
- try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
237
-
238
- const prefs = {
239
- contentCreate: body.contentCreate === true,
240
- contentUpdate: body.contentUpdate === true,
241
- contentDelete: body.contentDelete === true,
242
- mediaUpload: body.mediaUpload === true,
243
- }
244
-
245
- await c.env.DB.prepare('UPDATE users SET notification_prefs = ? WHERE id = ?').bind(JSON.stringify(prefs), sub).run()
246
- return c.json({ success: true })
247
- })
248
-
249
- export { settingsApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+
5
+ const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
+
7
+ const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
8
+ const MIN_PASSWORD_LENGTH = 8
9
+ const MAX_PASSWORD_LENGTH = 128
10
+ const SESSION_LIST_LIMIT = 20
11
+ const ACTIVITY_LOG_LIMIT = 30
12
+
13
+ /**
14
+ * GET /api/settings
15
+ * Retrieves the general site configuration.
16
+ */
17
+ settingsApp.get('/', async (context) => {
18
+ return context.json({
19
+ siteTitle: 'Beech CMS',
20
+ siteLogo: '/beechLogoDark.svg',
21
+ defaultLanguage: 'it',
22
+ dateFormat: context.env.DATE_FORMAT || 'DD-MM-YYYY',
23
+ features: {
24
+ drafts: true,
25
+ media: true,
26
+ search: true,
27
+ activityLog: true
28
+ }
29
+ })
30
+ })
31
+
32
+ /**
33
+ * GET /api/settings/me
34
+ * Retrieves the currently authenticated user's profile and preferences.
35
+ */
36
+ settingsApp.get('/me', async (context) => {
37
+ const { sub: userId } = context.get('jwtPayload')
38
+
39
+ const currentUser = await context.get('userRepository').findById(userId)
40
+ if (!currentUser) {
41
+ return context.json({ error: 'User not found' }, 404)
42
+ }
43
+
44
+ let notificationPreferences: Record<string, boolean>
45
+ try {
46
+ notificationPreferences = JSON.parse(currentUser.notificationPreferences || '{}')
47
+ } catch {
48
+ notificationPreferences = {}
49
+ }
50
+
51
+ return context.json({
52
+ id: currentUser.id,
53
+ email: currentUser.email,
54
+ name: currentUser.name,
55
+ avatarUrl: currentUser.avatarUrl,
56
+ notificationPrefs: {
57
+ contentCreate: notificationPreferences.contentCreate ?? true,
58
+ contentUpdate: notificationPreferences.contentUpdate ?? true,
59
+ contentDelete: notificationPreferences.contentDelete ?? true,
60
+ mediaUpload: notificationPreferences.mediaUpload ?? false,
61
+ },
62
+ })
63
+ })
64
+
65
+ /**
66
+ * PUT /api/settings/profile
67
+ * Updates the user's name and email address.
68
+ */
69
+ settingsApp.put('/profile', async (context) => {
70
+ const { sub: userId } = context.get('jwtPayload')
71
+
72
+ let payload: Record<string, unknown>
73
+ try {
74
+ payload = await context.req.json()
75
+ } catch {
76
+ return context.json({ error: 'Invalid JSON body' }, 400)
77
+ }
78
+
79
+ const nameInput = typeof payload.name === 'string' ? payload.name.trim() : null
80
+ const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
81
+
82
+ if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
83
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid email format' }, 400)
84
+ }
85
+
86
+ if (nameInput !== null && nameInput.length > 100) {
87
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
88
+ }
89
+
90
+ const hasNoFields = nameInput === null && emailInput === null
91
+ if (hasNoFields) {
92
+ return context.json({ error: 'No fields to update' }, 400)
93
+ }
94
+
95
+ if (emailInput !== null) {
96
+ const emailTaken = await context.get('userRepository').emailBelongsToAnotherUser(emailInput, userId)
97
+ if (emailTaken) {
98
+ return context.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email address is already in use' }, 409)
99
+ }
100
+ }
101
+
102
+ const fieldsToUpdate: { name?: string; email?: string } = {}
103
+ if (nameInput !== null) fieldsToUpdate.name = nameInput
104
+ if (emailInput !== null) fieldsToUpdate.email = emailInput
105
+
106
+ await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
107
+ return context.json({ success: true })
108
+ })
109
+
110
+ /**
111
+ * PUT /api/settings/password
112
+ * Updates the user's password after verifying the current one.
113
+ */
114
+ settingsApp.put('/password', async (context) => {
115
+ const { sub: userId } = context.get('jwtPayload')
116
+
117
+ let payload: Record<string, unknown>
118
+ try {
119
+ payload = await context.req.json()
120
+ } catch {
121
+ return context.json({ error: 'Invalid JSON body' }, 400)
122
+ }
123
+
124
+ const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : ''
125
+ const newPassword = typeof payload.newPassword === 'string' ? payload.newPassword : ''
126
+
127
+ if (!currentPassword || !newPassword) {
128
+ return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
129
+ }
130
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
131
+ return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
132
+ }
133
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
134
+ return context.json({ error: 'Password is too long' }, 400)
135
+ }
136
+
137
+ const userRecord = await context.get('userRepository').findById(userId)
138
+ if (!userRecord) {
139
+ return context.json({ error: 'User not found' }, 404)
140
+ }
141
+
142
+ const hashProvider = context.get('hashProvider')
143
+ const isPasswordCorrect = await hashProvider.verify(currentPassword, userRecord.passwordHash)
144
+ if (!isPasswordCorrect) {
145
+ return context.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Current password is incorrect' }, 401)
146
+ }
147
+
148
+ const hashedNewPassword = await hashProvider.hash(newPassword)
149
+ await context.get('userRepository').updatePasswordHash(userId, hashedNewPassword)
150
+ return context.json({ success: true })
151
+ })
152
+
153
+ /**
154
+ * PUT /api/settings/avatar
155
+ * Updates the user's avatar URL.
156
+ */
157
+ settingsApp.put('/avatar', async (context) => {
158
+ const { sub: userId } = context.get('jwtPayload')
159
+
160
+ let payload: Record<string, unknown>
161
+ try {
162
+ payload = await context.req.json()
163
+ } catch {
164
+ return context.json({ error: 'Invalid JSON body' }, 400)
165
+ }
166
+
167
+ const avatarUrl = typeof payload.avatarUrl === 'string' ? payload.avatarUrl.trim() : null
168
+ await context.get('userRepository').updateAvatarUrl(userId, avatarUrl)
169
+ return context.json({ success: true })
170
+ })
171
+
172
+ /**
173
+ * GET /api/settings/sessions
174
+ * Retrieves a list of active refresh tokens for the user.
175
+ */
176
+ settingsApp.get('/sessions', async (context) => {
177
+ const { sub: userId } = context.get('jwtPayload')
178
+ const nowTimestamp = Math.floor(Date.now() / 1000)
179
+ const sessions = await context.get('sessionRepository').listActiveForUser(userId, nowTimestamp, SESSION_LIST_LIMIT)
180
+ return context.json(sessions)
181
+ })
182
+
183
+ /**
184
+ * DELETE /api/settings/sessions/:id
185
+ * Revokes a specific refresh token (session).
186
+ */
187
+ settingsApp.delete('/sessions/:id', async (context) => {
188
+ const { sub: userId } = context.get('jwtPayload')
189
+ const sessionId = context.req.param('id')
190
+ const nowTimestamp = Math.floor(Date.now() / 1000)
191
+
192
+ const wasRevoked = await context.get('sessionRepository').revokeById(sessionId, userId, nowTimestamp)
193
+ if (!wasRevoked) {
194
+ return context.json({ error: 'Session not found or already revoked' }, 404)
195
+ }
196
+
197
+ return context.json({ success: true })
198
+ })
199
+
200
+ /**
201
+ * GET /api/settings/activity
202
+ * Retrieves the latest activity logs for the user.
203
+ */
204
+ settingsApp.get('/activity', async (context) => {
205
+ const { sub: userId } = context.get('jwtPayload')
206
+
207
+ const entries = await context.get('activityLogRepository').list({
208
+ userId,
209
+ limit: ACTIVITY_LOG_LIMIT,
210
+ })
211
+
212
+ // Preserve legacy snake_case shape consumed by the dashboard activity tab.
213
+ const responseEntries = entries.map((entry) => ({
214
+ id: entry.id,
215
+ action: entry.action,
216
+ entity_type: entry.entityType,
217
+ entity_slug: entry.entitySlug,
218
+ details: entry.details ? JSON.stringify(entry.details) : null,
219
+ created_at: entry.createdAt,
220
+ }))
221
+
222
+ return context.json(responseEntries)
223
+ })
224
+
225
+ /**
226
+ * GET /api/settings/storage
227
+ * Calculates storage usage and identifies orphaned media files.
228
+ */
229
+ settingsApp.get('/storage', async (context) => {
230
+ const mediaRepo = context.get('mediaRepository')
231
+ const statsRepo = context.get('systemStatsRepository')
232
+
233
+ const totalStorageUsedBytes = await statsRepo.getStorageUsage()
234
+ const totalFileCount = await mediaRepo.count()
235
+
236
+ const registeredSeeds = context.get('seedRegistry').all()
237
+ const referencedMediaKeys = await context.get('contentScanRepository').getReferencedMediaKeys(registeredSeeds)
238
+
239
+ const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
240
+ const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
241
+
242
+ return context.json({
243
+ totalBytes: totalStorageUsedBytes,
244
+ fileCount: totalFileCount,
245
+ orphans: orphanedMediaFiles,
246
+ })
247
+ })
248
+
249
+ /**
250
+ * GET /api/settings/notifications
251
+ * Retrieves the user's notification preferences.
252
+ */
253
+ settingsApp.get('/notifications', async (context) => {
254
+ const { sub: userId } = context.get('jwtPayload')
255
+
256
+ const userRecord = await context.get('userRepository').findById(userId)
257
+ if (!userRecord) {
258
+ return context.json({ error: 'User not found' }, 404)
259
+ }
260
+
261
+ let userPreferences: Record<string, boolean>
262
+ try {
263
+ userPreferences = JSON.parse(userRecord.notificationPreferences || '{}')
264
+ } catch {
265
+ userPreferences = {}
266
+ }
267
+
268
+ return context.json({
269
+ contentCreate: userPreferences.contentCreate ?? true,
270
+ contentUpdate: userPreferences.contentUpdate ?? true,
271
+ contentDelete: userPreferences.contentDelete ?? true,
272
+ mediaUpload: userPreferences.mediaUpload ?? false,
273
+ })
274
+ })
275
+
276
+ /**
277
+ * PUT /api/settings/notifications
278
+ * Updates the user's notification preferences.
279
+ */
280
+ settingsApp.put('/notifications', async (context) => {
281
+ const { sub: userId } = context.get('jwtPayload')
282
+
283
+ let payload: Record<string, unknown>
284
+ try {
285
+ payload = await context.req.json()
286
+ } catch {
287
+ return context.json({ error: 'Invalid JSON body' }, 400)
288
+ }
289
+
290
+ const newPreferences = {
291
+ contentCreate: payload.contentCreate === true,
292
+ contentUpdate: payload.contentUpdate === true,
293
+ contentDelete: payload.contentDelete === true,
294
+ mediaUpload: payload.mediaUpload === true,
295
+ }
296
+
297
+ await context.get('userRepository').updateNotificationPreferences(userId, JSON.stringify(newPreferences))
298
+ return context.json({ success: true })
299
+ })
300
+
301
+ export { settingsApp }