@beechcms/api 0.4.0-preview.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -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/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -303
  15. package/src/features/content/constants.ts +10 -0
  16. package/src/features/content/handlers/create.ts +153 -0
  17. package/src/features/content/handlers/delete.ts +76 -0
  18. package/src/features/content/handlers/facets.ts +45 -0
  19. package/src/features/content/handlers/get.ts +116 -0
  20. package/src/features/content/handlers/list.ts +88 -0
  21. package/src/features/content/handlers/update.ts +207 -0
  22. package/src/features/content/index.ts +20 -0
  23. package/src/features/draft/draft.handler.ts +272 -198
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -249
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -11
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -0
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -183
  56. package/src/public/public-edit.ts +158 -183
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -217
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -241
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -0
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -0
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -0
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -36
  83. package/src/upload.ts +179 -335
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  86. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  87. package/src/content.ts +0 -502
  88. package/src/features/draft/draft.test.ts +0 -315
  89. package/src/features/rotate-field/rotate-field.test.ts +0 -297
@@ -1,249 +1,414 @@
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 bcrypt from 'bcryptjs'
4
+ import type { Env, Variables } from '../../types'
5
+
6
+ const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
7
+
8
+ const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
9
+ const MIN_PASSWORD_LENGTH = 8
10
+ const MAX_PASSWORD_LENGTH = 128
11
+ const BCRYPT_SALT_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 MediaFileRow = {
38
+ key: string
39
+ filename: string
40
+ mime_type: string
41
+ size_bytes: number
42
+ created_at: number
43
+ }
44
+
45
+
46
+ /**
47
+ * GET /api/settings
48
+ * Retrieves the general site configuration.
49
+ */
50
+ settingsApp.get('/', async (context) => {
51
+ // General site configuration.
52
+ // In the future, these could be loaded from a 'system_settings' table in D1.
53
+ return context.json({
54
+ siteTitle: 'Beech CMS',
55
+ siteLogo: '/beechLogoDark.svg',
56
+ defaultLanguage: 'it',
57
+ dateFormat: context.env.DATE_FORMAT || 'DD-MM-YYYY',
58
+ features: {
59
+ drafts: true,
60
+ media: true,
61
+ search: true,
62
+ activityLog: true
63
+ }
64
+ })
65
+ })
66
+
67
+ /**
68
+ * GET /api/settings/me
69
+ * Retrieves the currently authenticated user's profile and preferences.
70
+ */
71
+ settingsApp.get('/me', async (context) => {
72
+ const { sub: userId } = context.get('jwtPayload')
73
+
74
+ const currentUser = await context.env.DB.prepare(
75
+ 'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
76
+ ).bind(userId).first<Omit<UserRow, 'password_hash'>>()
77
+
78
+ if (!currentUser) {
79
+ return context.json({ error: 'User not found' }, 404)
80
+ }
81
+
82
+ let notificationPreferences: Record<string, boolean>
83
+ try {
84
+ notificationPreferences = JSON.parse(currentUser.notification_prefs || '{}')
85
+ } catch {
86
+ notificationPreferences = {}
87
+ }
88
+
89
+ return context.json({
90
+ id: currentUser.id,
91
+ email: currentUser.email,
92
+ name: currentUser.name,
93
+ avatarUrl: currentUser.avatar_url,
94
+ notificationPrefs: {
95
+ contentCreate: notificationPreferences.contentCreate ?? true,
96
+ contentUpdate: notificationPreferences.contentUpdate ?? true,
97
+ contentDelete: notificationPreferences.contentDelete ?? true,
98
+ mediaUpload: notificationPreferences.mediaUpload ?? false,
99
+ },
100
+ })
101
+ })
102
+
103
+ /**
104
+ * PUT /api/settings/profile
105
+ * Updates the user's name and email address.
106
+ */
107
+ settingsApp.put('/profile', async (context) => {
108
+ const { sub: userId } = context.get('jwtPayload')
109
+
110
+ let payload: Record<string, unknown>
111
+ try {
112
+ payload = await context.req.json()
113
+ } catch {
114
+ return context.json({ error: 'Invalid JSON body' }, 400)
115
+ }
116
+
117
+ const nameInput = typeof payload.name === 'string' ? payload.name.trim() : null
118
+ const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
119
+
120
+ if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
121
+ return context.json({
122
+ type: 'bad-request',
123
+ title: 'Bad Request',
124
+ status: 400,
125
+ detail: 'Invalid email format'
126
+ }, 400)
127
+ }
128
+
129
+ if (nameInput !== null && nameInput.length > 100) {
130
+ return context.json({
131
+ type: 'bad-request',
132
+ title: 'Bad Request',
133
+ status: 400,
134
+ detail: 'Name is too long (maximum 100 characters)'
135
+ }, 400)
136
+ }
137
+
138
+ const fieldsToUpdate: string[] = []
139
+ const valuesToUpdate: unknown[] = []
140
+
141
+ if (nameInput !== null) {
142
+ fieldsToUpdate.push('name = ?')
143
+ valuesToUpdate.push(nameInput)
144
+ }
145
+
146
+ if (emailInput !== null) {
147
+ fieldsToUpdate.push('email = ?')
148
+ valuesToUpdate.push(emailInput)
149
+ }
150
+
151
+ if (fieldsToUpdate.length === 0) {
152
+ return context.json({ error: 'No fields to update' }, 400)
153
+ }
154
+
155
+ // Check if the new email is already taken by another user
156
+ if (emailInput !== null) {
157
+ const existingUserWithEmail = await context.env.DB.prepare(
158
+ 'SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1'
159
+ ).bind(emailInput, userId).first()
160
+
161
+ if (existingUserWithEmail) {
162
+ return context.json({
163
+ type: 'conflict',
164
+ title: 'Conflict',
165
+ status: 409,
166
+ detail: 'Email address is already in use'
167
+ }, 409)
168
+ }
169
+ }
170
+
171
+ valuesToUpdate.push(userId)
172
+ await context.env.DB.prepare(
173
+ `UPDATE users SET ${fieldsToUpdate.join(', ')} WHERE id = ?`
174
+ ).bind(...valuesToUpdate).run()
175
+
176
+ return context.json({ success: true })
177
+ })
178
+
179
+ /**
180
+ * PUT /api/settings/password
181
+ * Updates the user's password after verifying the current one.
182
+ */
183
+ settingsApp.put('/password', async (context) => {
184
+ const { sub: userId } = context.get('jwtPayload')
185
+
186
+ let payload: Record<string, unknown>
187
+ try {
188
+ payload = await context.req.json()
189
+ } catch {
190
+ return context.json({ error: 'Invalid JSON body' }, 400)
191
+ }
192
+
193
+ const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : ''
194
+ const newPassword = typeof payload.newPassword === 'string' ? payload.newPassword : ''
195
+
196
+ if (!currentPassword || !newPassword) {
197
+ return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
198
+ }
199
+
200
+ if (newPassword.length < MIN_PASSWORD_LENGTH) {
201
+ return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
202
+ }
203
+
204
+ if (newPassword.length > MAX_PASSWORD_LENGTH) {
205
+ return context.json({ error: 'Password is too long' }, 400)
206
+ }
207
+
208
+ const userRecord = await context.env.DB.prepare(
209
+ 'SELECT password_hash FROM users WHERE id = ? LIMIT 1'
210
+ ).bind(userId).first<{ password_hash: string }>()
211
+
212
+ if (!userRecord) {
213
+ return context.json({ error: 'User not found' }, 404)
214
+ }
215
+
216
+ const isPasswordCorrect = await bcrypt.compare(currentPassword, userRecord.password_hash)
217
+ if (!isPasswordCorrect) {
218
+ return context.json({
219
+ type: 'invalid-credentials',
220
+ title: 'Unauthorized',
221
+ status: 401,
222
+ detail: 'Current password is incorrect'
223
+ }, 401)
224
+ }
225
+
226
+ const hashedNewPassword = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS)
227
+ await context.env.DB.prepare(
228
+ 'UPDATE users SET password_hash = ? WHERE id = ?'
229
+ ).bind(hashedNewPassword, userId).run()
230
+
231
+ return context.json({ success: true })
232
+ })
233
+
234
+ /**
235
+ * PUT /api/settings/avatar
236
+ * Updates the user's avatar URL.
237
+ */
238
+ settingsApp.put('/avatar', async (context) => {
239
+ const { sub: userId } = context.get('jwtPayload')
240
+
241
+ let payload: Record<string, unknown>
242
+ try {
243
+ payload = await context.req.json()
244
+ } catch {
245
+ return context.json({ error: 'Invalid JSON body' }, 400)
246
+ }
247
+
248
+ const avatarUrl = typeof payload.avatarUrl === 'string' ? payload.avatarUrl.trim() : null
249
+
250
+ await context.env.DB.prepare(
251
+ 'UPDATE users SET avatar_url = ? WHERE id = ?'
252
+ ).bind(avatarUrl, userId).run()
253
+
254
+ return context.json({ success: true })
255
+ })
256
+
257
+ /**
258
+ * GET /api/settings/sessions
259
+ * Retrieves a list of active refresh tokens for the user.
260
+ */
261
+ settingsApp.get('/sessions', async (context) => {
262
+ const { sub: userId } = context.get('jwtPayload')
263
+ const currentTimestamp = Math.floor(Date.now() / 1000)
264
+
265
+ const sessionsResult = await context.env.DB.prepare(
266
+ `SELECT id, created_at, expires_at FROM refresh_tokens
267
+ WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
268
+ ORDER BY created_at DESC LIMIT 20`
269
+ ).bind(userId, currentTimestamp).all<SessionRow>()
270
+
271
+ return context.json(sessionsResult.results ?? [])
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 currentTimestamp = Math.floor(Date.now() / 1000)
282
+
283
+ const dbUpdateResult = await context.env.DB.prepare(
284
+ `UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
285
+ ).bind(currentTimestamp, sessionId, userId).run()
286
+
287
+ const affectedRowsCount = (dbUpdateResult as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
288
+
289
+ if (affectedRowsCount === 0) {
290
+ return context.json({ error: 'Session not found or already revoked' }, 404)
291
+ }
292
+
293
+ return context.json({ success: true })
294
+ })
295
+
296
+ /**
297
+ * GET /api/settings/activity
298
+ * Retrieves the latest activity logs for the user.
299
+ */
300
+ settingsApp.get('/activity', async (context) => {
301
+ const { sub: userId } = context.get('jwtPayload')
302
+
303
+ const activityLogsResult = await context.env.DB.prepare(
304
+ `SELECT id, action, entity_type, entity_slug, details, created_at
305
+ FROM activity_logs WHERE user_id = ?
306
+ ORDER BY created_at DESC LIMIT 30`
307
+ ).bind(userId).all<ActivityRow>()
308
+
309
+ return context.json(activityLogsResult.results ?? [])
310
+ })
311
+
312
+ /**
313
+ * GET /api/settings/storage
314
+ * Calculates storage usage and identifies orphaned media files.
315
+ */
316
+ settingsApp.get('/storage', async (context) => {
317
+ const mediaRepo = context.get('mediaRepository')
318
+ const statsRepo = context.get('systemStatsRepository')
319
+
320
+ const totalStorageUsedBytes = await statsRepo.getStorageUsage()
321
+ const totalFileCount = await mediaRepo.count()
322
+
323
+ // Collect all media keys referenced in file-type columns across all seeds
324
+ const referencedMediaKeys = new Set<string>()
325
+ const registeredSeeds = Object.values(context.get('seedRegistry'))
326
+
327
+ for (const seed of registeredSeeds) {
328
+ const mediaFields = seed.branches.filter(branch => branch.type === 'file')
329
+ if (mediaFields.length === 0) continue
330
+
331
+ const mediaColumns = mediaFields.map(field => field.alias).join(', ')
332
+ const contentData = await context.env.DB.prepare(
333
+ `SELECT ${mediaColumns} FROM content_${seed.slug}`
334
+ ).all<Record<string, string | null>>()
335
+
336
+ for (const contentRow of contentData.results ?? []) {
337
+ const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
338
+ // Simple regex to find media keys in stored URLs or strings
339
+ for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
340
+ referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
341
+ }
342
+ }
343
+ }
344
+
345
+ const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
346
+ const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
347
+
348
+ return context.json({
349
+ totalBytes: totalStorageUsedBytes,
350
+ fileCount: totalFileCount,
351
+ orphans: orphanedMediaFiles,
352
+ })
353
+ })
354
+
355
+ /**
356
+ * GET /api/settings/notifications
357
+ * Retrieves the user's notification preferences.
358
+ */
359
+ settingsApp.get('/notifications', async (context) => {
360
+ const { sub: userId } = context.get('jwtPayload')
361
+
362
+ const userRecord = await context.env.DB.prepare(
363
+ 'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
364
+ ).bind(userId).first<{ notification_prefs: string }>()
365
+
366
+ if (!userRecord) {
367
+ return context.json({ error: 'User not found' }, 404)
368
+ }
369
+
370
+ let userPreferences: Record<string, boolean>
371
+ try {
372
+ userPreferences = JSON.parse(userRecord.notification_prefs || '{}')
373
+ } catch {
374
+ userPreferences = {}
375
+ }
376
+
377
+ return context.json({
378
+ contentCreate: userPreferences.contentCreate ?? true,
379
+ contentUpdate: userPreferences.contentUpdate ?? true,
380
+ contentDelete: userPreferences.contentDelete ?? true,
381
+ mediaUpload: userPreferences.mediaUpload ?? false,
382
+ })
383
+ })
384
+
385
+ /**
386
+ * PUT /api/settings/notifications
387
+ * Updates the user's notification preferences.
388
+ */
389
+ settingsApp.put('/notifications', async (context) => {
390
+ const { sub: userId } = context.get('jwtPayload')
391
+
392
+ let payload: Record<string, unknown>
393
+ try {
394
+ payload = await context.req.json()
395
+ } catch {
396
+ return context.json({ error: 'Invalid JSON body' }, 400)
397
+ }
398
+
399
+ const newPreferences = {
400
+ contentCreate: payload.contentCreate === true,
401
+ contentUpdate: payload.contentUpdate === true,
402
+ contentDelete: payload.contentDelete === true,
403
+ mediaUpload: payload.mediaUpload === true,
404
+ }
405
+
406
+ await context.env.DB.prepare(
407
+ 'UPDATE users SET notification_prefs = ? WHERE id = ?'
408
+ ).bind(JSON.stringify(newPreferences), userId).run()
409
+
410
+ return context.json({ success: true })
411
+ })
412
+
413
+ export { settingsApp }
414
+