@beechcms/api 0.4.0-preview.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 (61) hide show
  1. package/README.md +21 -0
  2. package/migrations/0000_v040_base.sql +213 -0
  3. package/package.json +36 -0
  4. package/src/auth/constants.ts +10 -0
  5. package/src/auth/login.ts +91 -0
  6. package/src/auth/refresh.ts +127 -0
  7. package/src/content.ts +502 -0
  8. package/src/factory.ts +56 -0
  9. package/src/features/draft/draft.handler.ts +198 -0
  10. package/src/features/draft/draft.test.ts +315 -0
  11. package/src/features/draft/index.ts +1 -0
  12. package/src/features/email/email.provider.ts +38 -0
  13. package/src/features/email/email.service.ts +80 -0
  14. package/src/features/email/email.types.ts +98 -0
  15. package/src/features/email/index.ts +28 -0
  16. package/src/features/email/providers/resend.ts +63 -0
  17. package/src/features/email/templates/password-changed.ts +59 -0
  18. package/src/features/email/templates/password-reset.ts +64 -0
  19. package/src/features/email/templates/shell.ts +93 -0
  20. package/src/features/notifications/index.ts +1 -0
  21. package/src/features/notifications/notifications.handler.ts +88 -0
  22. package/src/features/password-reset/index.ts +15 -0
  23. package/src/features/password-reset/request.ts +88 -0
  24. package/src/features/password-reset/reset.ts +110 -0
  25. package/src/features/rotate-field/index.ts +1 -0
  26. package/src/features/rotate-field/rotate-field.handler.ts +82 -0
  27. package/src/features/rotate-field/rotate-field.schema.ts +9 -0
  28. package/src/features/rotate-field/rotate-field.test.ts +297 -0
  29. package/src/features/settings/settings.handler.ts +249 -0
  30. package/src/features/setup/index.ts +59 -0
  31. package/src/features/stats/index.ts +1 -0
  32. package/src/features/stats/stats.handler.ts +395 -0
  33. package/src/index.ts +344 -0
  34. package/src/media-utils.ts +78 -0
  35. package/src/middleware.ts +67 -0
  36. package/src/public/access-policy.ts +23 -0
  37. package/src/public/api-key-middleware.ts +53 -0
  38. package/src/public/index.ts +12 -0
  39. package/src/public/problem-details.ts +42 -0
  40. package/src/public/public-add.ts +183 -0
  41. package/src/public/public-edit.ts +183 -0
  42. package/src/public/public-errors.ts +15 -0
  43. package/src/public/public-read.ts +217 -0
  44. package/src/public/public-routes.ts +31 -0
  45. package/src/public/query-builder.ts +241 -0
  46. package/src/public/rate-limit-middleware.ts +42 -0
  47. package/src/public/response-builder.ts +26 -0
  48. package/src/public/sanitize.ts +65 -0
  49. package/src/public/slug-utils.ts +14 -0
  50. package/src/search-utils.ts +188 -0
  51. package/src/search.ts +72 -0
  52. package/src/shared/activity-logger.ts +79 -0
  53. package/src/shared/apply-policies.ts +63 -0
  54. package/src/shared/content-utils.ts +108 -0
  55. package/src/shared/fts-sync.ts +4 -0
  56. package/src/shared/notification-service.ts +56 -0
  57. package/src/shared/query-utils.ts +137 -0
  58. package/src/shared/storage-utils.ts +36 -0
  59. package/src/types.ts +35 -0
  60. package/src/upload.ts +335 -0
  61. package/src/widget.ts +349 -0
@@ -0,0 +1,249 @@
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 }
@@ -0,0 +1,59 @@
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
+ export const setupApp = new Hono<{ Bindings: Env; Variables: Variables }>()
7
+
8
+ /** GET /auth/setup — tells the dashboard whether first-run setup is needed */
9
+ setupApp.get('/auth/setup', async (c) => {
10
+ const row = await c.env.DB
11
+ .prepare('SELECT COUNT(*) as count FROM users')
12
+ .first<{ count: number }>()
13
+ return c.json({ needsSetup: (row?.count ?? 0) === 0 })
14
+ })
15
+
16
+ /** POST /auth/setup — creates the first admin; rejected once any user exists */
17
+ setupApp.post('/auth/setup', async (c) => {
18
+ const row = await c.env.DB
19
+ .prepare('SELECT COUNT(*) as count FROM users')
20
+ .first<{ count: number }>()
21
+
22
+ if ((row?.count ?? 0) > 0) {
23
+ return c.json(
24
+ { type: 'https://beech.local/errors/setup-already-done', title: 'Setup already completed', status: 403 },
25
+ 403
26
+ )
27
+ }
28
+
29
+ let body: unknown
30
+ try { body = await c.req.json() } catch {
31
+ return c.json({ type: 'https://beech.local/errors/bad-request', title: 'Invalid JSON body', status: 400 }, 400)
32
+ }
33
+
34
+ if (!body || typeof body !== 'object') {
35
+ return c.json({ type: 'https://beech.local/errors/bad-request', title: 'Invalid request', status: 400 }, 400)
36
+ }
37
+
38
+ const { email, password, name } = body as Record<string, unknown>
39
+
40
+ if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
41
+ return c.json({ type: 'https://beech.local/errors/validation', title: 'Valid email required', status: 422 }, 422)
42
+ }
43
+
44
+ if (typeof password !== 'string' || password.length < 8 || password.length > 128) {
45
+ return c.json({ type: 'https://beech.local/errors/validation', title: 'Password must be 8–128 characters', status: 422 }, 422)
46
+ }
47
+
48
+ const passwordHash = await bcrypt.hash(password, 12)
49
+ const id = crypto.randomUUID()
50
+ const cleanEmail = email.trim().toLowerCase()
51
+ const cleanName = typeof name === 'string' ? name.trim() : null
52
+
53
+ await c.env.DB
54
+ .prepare('INSERT INTO users (id, email, password_hash, role, name) VALUES (?, ?, ?, ?, ?)')
55
+ .bind(id, cleanEmail, passwordHash, 'admin', cleanName)
56
+ .run()
57
+
58
+ return c.json({ success: true }, 201)
59
+ })
@@ -0,0 +1 @@
1
+ export { statsApp } from './stats.handler'