@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13

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 (85) 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-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
  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 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  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 -267
  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 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  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 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  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 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  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 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
@@ -1,88 +1,130 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
-
5
- const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
6
-
7
- // GET /notifications — lista con ETag/304
8
- notificationsApp.get('/notifications', async (c) => {
9
- try {
10
- const { DB } = c.env
11
-
12
- const stats = await DB.prepare(
13
- 'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
14
- ).first<{ count: number; latest: number | null; read_sum: number | null }>()
15
-
16
- const count = stats?.count ?? 0
17
- const latest = stats?.latest ?? 0
18
- const readSum = stats?.read_sum ?? 0
19
- const etag = `W/"${count}-${latest}-${readSum}"`
20
-
21
- const ifNoneMatch = c.req.header('If-None-Match')
22
- if (ifNoneMatch === etag) {
23
- return new Response(null, { status: 304 })
24
- }
25
-
26
- const result = await DB.prepare(
27
- 'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
28
- ).all()
29
-
30
- c.header('ETag', etag)
31
- c.header('Cache-Control', 'no-cache, must-revalidate')
32
-
33
- return c.json(result.results ?? [])
34
- } catch (err) {
35
- console.error('Notifications fetch error:', err)
36
- return c.json({ error: 'Failed to fetch notifications' }, 500)
37
- }
38
- })
39
-
40
-
41
- // PATCH /notifications/:id/read — segna come letta
42
- notificationsApp.patch('/notifications/:id/read', async (c) => {
43
- try {
44
- const id = c.req.param('id')
45
- const { DB } = c.env
46
- await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').bind(id).run()
47
- return c.json({ success: true })
48
- } catch (err) {
49
- return c.json({ error: 'Failed to update notification' }, 500)
50
- }
51
- })
52
- // PATCH /notifications/:id/unread - Segna come non letta
53
- notificationsApp.patch('/notifications/:id/unread', async (c) => {
54
- try {
55
- const id = c.req.param('id')
56
- const { DB } = c.env
57
- await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?').bind(id).run()
58
- return c.json({ success: true })
59
- } catch (err) {
60
- return c.json({ error: 'Failed to update notification' }, 500)
61
- }
62
- })
63
-
64
-
65
- // DELETE /notifications/:id — elimina notifica
66
- notificationsApp.delete('/notifications/:id', async (c) => {
67
- try {
68
- const id = c.req.param('id')
69
- const { DB } = c.env
70
- await DB.prepare('DELETE FROM notifications WHERE id = ?').bind(id).run()
71
- return c.json({ success: true })
72
- } catch (err) {
73
- return c.json({ error: 'Failed to delete notification' }, 500)
74
- }
75
- })
76
-
77
-
78
- // POST /notifications/mark-all-read — segna tutte come lette
79
- notificationsApp.post('/notifications/mark-all-read', async (c) => {
80
- try {
81
- const { DB } = c.env
82
- await DB.prepare('UPDATE notifications SET is_read = 1').run()
83
- return c.json({ success: true })
84
- } catch (err) {
85
- return c.json({ error: 'Failed to update notifications' }, 500)
86
- }
87
- })
88
- export { notificationsApp }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+
5
+ /**
6
+ * Notifications Feature Handler
7
+ *
8
+ * Manages the retrieval, marking as read/unread, and deletion of system notifications.
9
+ * Uses ETags for efficient client-side caching of the notification list.
10
+ */
11
+ const notificationsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
12
+
13
+ /**
14
+ * GET /notifications
15
+ * Fetches the latest 50 notifications.
16
+ * Implements ETag/304 Not Modified caching based on count and last update.
17
+ */
18
+ notificationsApp.get('/notifications', async (context) => {
19
+ try {
20
+ const { DB } = context.env
21
+
22
+ // Fetch aggregate stats to generate a robust ETag
23
+ const stats = await DB.prepare(
24
+ 'SELECT COUNT(*) as count, MAX(created_at) as latest, SUM(is_read) as read_sum FROM notifications'
25
+ ).first<{ count: number; latest: number | null; read_sum: number | null }>()
26
+
27
+ const totalCount = stats?.count ?? 0
28
+ const lastUpdateTimestamp = stats?.latest ?? 0
29
+ const totalReadCount = stats?.read_sum ?? 0
30
+
31
+ // ETag is derived from count, latest timestamp, and read status to ensure freshness
32
+ const etag = `W/"${totalCount}-${lastUpdateTimestamp}-${totalReadCount}"`
33
+
34
+ const ifNoneMatch = context.req.header('If-None-Match')
35
+ if (ifNoneMatch === etag) {
36
+ return new Response(null, { status: 304 })
37
+ }
38
+
39
+ const dbResult = await DB.prepare(
40
+ 'SELECT id, title, message, type, is_read, created_at FROM notifications ORDER BY created_at DESC LIMIT 50'
41
+ ).all()
42
+
43
+ context.header('ETag', etag)
44
+ context.header('Cache-Control', 'no-cache, must-revalidate')
45
+
46
+ return context.json(dbResult.results ?? [])
47
+ } catch (error) {
48
+ console.error('[Notifications] Fetch error:', error)
49
+ return context.json({ error: 'Failed to fetch notifications' }, 500)
50
+ }
51
+ })
52
+
53
+ /**
54
+ * PATCH /notifications/:id/read
55
+ * Marks a specific notification as read.
56
+ */
57
+ notificationsApp.patch('/notifications/:id/read', async (context) => {
58
+ try {
59
+ const notificationId = context.req.param('id')
60
+ const { DB } = context.env
61
+
62
+ await DB.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
63
+ .bind(notificationId)
64
+ .run()
65
+
66
+ return context.json({ success: true })
67
+ } catch (error) {
68
+ console.error('[Notifications] Mark read error:', error)
69
+ return context.json({ error: 'Failed to update notification' }, 500)
70
+ }
71
+ })
72
+
73
+ /**
74
+ * PATCH /notifications/:id/unread
75
+ * Marks a specific notification as unread.
76
+ */
77
+ notificationsApp.patch('/notifications/:id/unread', async (context) => {
78
+ try {
79
+ const notificationId = context.req.param('id')
80
+ const { DB } = context.env
81
+
82
+ await DB.prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
83
+ .bind(notificationId)
84
+ .run()
85
+
86
+ return context.json({ success: true })
87
+ } catch (error) {
88
+ console.error('[Notifications] Mark unread error:', error)
89
+ return context.json({ error: 'Failed to update notification' }, 500)
90
+ }
91
+ })
92
+
93
+ /**
94
+ * DELETE /notifications/:id
95
+ * Permanently deletes a notification.
96
+ */
97
+ notificationsApp.delete('/notifications/:id', async (context) => {
98
+ try {
99
+ const notificationId = context.req.param('id')
100
+ const { DB } = context.env
101
+
102
+ await DB.prepare('DELETE FROM notifications WHERE id = ?')
103
+ .bind(notificationId)
104
+ .run()
105
+
106
+ return context.json({ success: true })
107
+ } catch (error) {
108
+ console.error('[Notifications] Delete error:', error)
109
+ return context.json({ error: 'Failed to delete notification' }, 500)
110
+ }
111
+ })
112
+
113
+ /**
114
+ * POST /notifications/mark-all-read
115
+ * Marks all notifications in the database as read.
116
+ */
117
+ notificationsApp.post('/notifications/mark-all-read', async (context) => {
118
+ try {
119
+ const { DB } = context.env
120
+
121
+ await DB.prepare('UPDATE notifications SET is_read = 1').run()
122
+
123
+ return context.json({ success: true })
124
+ } catch (error) {
125
+ console.error('[Notifications] Mark all read error:', error)
126
+ return context.json({ error: 'Failed to update notifications' }, 500)
127
+ }
128
+ })
129
+
130
+ export { notificationsApp }
@@ -1,15 +1,15 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import { Hono } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
- import { requestPasswordReset } from './request'
5
- import { resetPassword } from './reset'
6
-
7
- export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
-
9
- // Feature flag: lets the dashboard know whether to show the forgot-password link
10
- passwordResetApp.get('/auth/features', (c) => {
11
- return c.json({ passwordReset: Boolean(c.env.RESEND_API_KEY) })
12
- })
13
-
14
- passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
15
- passwordResetApp.post('/auth/reset-password', resetPassword)
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { Hono } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { requestPasswordReset } from './request'
5
+ import { resetPassword } from './reset'
6
+
7
+ export const passwordResetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
8
+
9
+ // Feature flag: lets the dashboard know whether to show the forgot-password link
10
+ passwordResetApp.get('/auth/features', (context) => {
11
+ return context.json({ passwordReset: Boolean(context.env.RESEND_API_KEY) })
12
+ })
13
+
14
+ passwordResetApp.post('/auth/forgot-password', requestPasswordReset)
15
+ passwordResetApp.post('/auth/reset-password', resetPassword)
@@ -1,88 +1,106 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { Context } from 'hono'
3
- import type { Env, Variables } from '../../types'
4
- import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
-
6
- const TOKEN_EXPIRY_SECONDS = 30 * 60
7
-
8
- async function sha256hex(text: string): Promise<string> {
9
- const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
10
- return Array.from(new Uint8Array(buf))
11
- .map(b => b.toString(16).padStart(2, '0'))
12
- .join('')
13
- }
14
-
15
- export async function requestPasswordReset(
16
- c: Context<{ Bindings: Env; Variables: Variables }>
17
- ): Promise<Response> {
18
- if (!c.env.RESEND_API_KEY) {
19
- return c.json({ error: 'Not available' }, 503)
20
- }
21
-
22
- let body: Record<string, unknown>
23
- try {
24
- body = await c.req.json()
25
- } catch {
26
- return c.json({ error: 'Invalid request body' }, 400)
27
- }
28
-
29
- if (typeof body.email !== 'string' || !body.email.trim()) {
30
- return c.json({ error: 'Invalid request' }, 400)
31
- }
32
-
33
- const email = body.email.trim().toLowerCase()
34
- const locale = resolveEmailLocale(body.locale)
35
-
36
- if (c.env.FORGOT_PASSWORD_RATE_LIMITER) {
37
- const ip = c.req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
38
- const { success } = await c.env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: ip })
39
- if (!success) {
40
- return c.json({ error: 'Too many requests' }, 429)
41
- }
42
- }
43
-
44
- // Sempre 200 per evitare user enumeration
45
- const user = await c.env.DB
46
- .prepare('SELECT id FROM users WHERE email = ?')
47
- .bind(email)
48
- .first<{ id: string }>()
49
-
50
- if (!user) {
51
- return c.json({ success: true })
52
- }
53
-
54
- // Invalida eventuali token pendenti per lo stesso utente prima di emetterne uno nuovo
55
- await c.env.DB
56
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
57
- .bind(user.id)
58
- .run()
59
-
60
- const token = crypto.randomUUID()
61
- const tokenHash = await sha256hex(token)
62
- const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS
63
-
64
- await c.env.DB
65
- .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
66
- .bind(crypto.randomUUID(), user.id, tokenHash, expiresAt)
67
- .run()
68
-
69
- const appUrl = (c.env.APP_URL ?? new URL(c.req.url).origin).replace(/\/$/, '')
70
- const resetUrl = `${appUrl}/admin/reset-password?token=${token}`
71
-
72
- try {
73
- await sendPasswordResetEmail({
74
- to: email,
75
- resetUrl,
76
- locale,
77
- apiKey: c.env.RESEND_API_KEY,
78
- from: c.env.EMAIL_FROM,
79
- isDev: c.env.ENV !== 'production',
80
- })
81
- } catch (err) {
82
- if (c.env.ENV !== 'production') {
83
- console.error('[password-reset] invio email fallito:', err)
84
- }
85
- }
86
-
87
- return c.json({ success: true })
88
- }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Context } from 'hono'
3
+ import type { Env, Variables } from '../../types'
4
+ import { sendPasswordResetEmail, resolveEmailLocale } from '../email'
5
+
6
+ const PASSWORD_RESET_TOKEN_EXPIRY_SECONDS = 30 * 60
7
+
8
+ /**
9
+ * Computes the SHA-256 hash of a string and returns it as a hex string.
10
+ */
11
+ async function computeSha256Hash(text: string): Promise<string> {
12
+ const encoder = new TextEncoder()
13
+ const data = encoder.encode(text)
14
+ const hashBuffer = await crypto.subtle.digest('SHA-256', data)
15
+
16
+ return Array.from(new Uint8Array(hashBuffer))
17
+ .map(byte => byte.toString(16).padStart(2, '0'))
18
+ .join('')
19
+ }
20
+
21
+ /**
22
+ * Handles the password reset request.
23
+ * Generates a reset token, stores its hash in the database, and sends an email to the user.
24
+ */
25
+ export async function requestPasswordReset(
26
+ context: Context<{ Bindings: Env; Variables: Variables }>
27
+ ): Promise<Response> {
28
+ const { env, req } = context
29
+
30
+ if (!env.RESEND_API_KEY) {
31
+ return context.json({ error: 'Service not available' }, 503)
32
+ }
33
+
34
+ let payload: Record<string, unknown>
35
+ try {
36
+ payload = await req.json()
37
+ } catch {
38
+ return context.json({ error: 'Invalid request body' }, 400)
39
+ }
40
+
41
+ const emailInput = payload.email
42
+ if (typeof emailInput !== 'string' || !emailInput.trim()) {
43
+ return context.json({ error: 'Invalid request' }, 400)
44
+ }
45
+
46
+ const normalizedEmail = emailInput.trim().toLowerCase()
47
+ const emailLocale = resolveEmailLocale(payload.locale)
48
+
49
+ // Rate limiting based on IP address
50
+ if (env.FORGOT_PASSWORD_RATE_LIMITER) {
51
+ const clientIpAddress = req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
52
+ const { success: isRateLimitAllowed } = await env.FORGOT_PASSWORD_RATE_LIMITER.limit({ key: clientIpAddress })
53
+
54
+ if (!isRateLimitAllowed) {
55
+ return context.json({ error: 'Too many requests' }, 429)
56
+ }
57
+ }
58
+
59
+ // Find user by email. We always return 200 success even if the user is not found to prevent user enumeration.
60
+ const registeredUser = await env.DB
61
+ .prepare('SELECT id FROM users WHERE email = ?')
62
+ .bind(normalizedEmail)
63
+ .first<{ id: string }>()
64
+
65
+ if (!registeredUser) {
66
+ return context.json({ success: true })
67
+ }
68
+
69
+ // Invalidate any existing pending tokens for the same user before issuing a new one.
70
+ await env.DB
71
+ .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE user_id = ? AND used_at IS NULL')
72
+ .bind(registeredUser.id)
73
+ .run()
74
+
75
+ const resetToken = crypto.randomUUID()
76
+ const hashedResetToken = await computeSha256Hash(resetToken)
77
+ const expirationTimestamp = Math.floor(Date.now() / 1000) + PASSWORD_RESET_TOKEN_EXPIRY_SECONDS
78
+
79
+ // Store the hashed token in the database
80
+ await env.DB
81
+ .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
82
+ .bind(crypto.randomUUID(), registeredUser.id, hashedResetToken, expirationTimestamp)
83
+ .run()
84
+
85
+ const baseUrl = (env.APP_URL ?? new URL(req.url).origin).replace(/\/$/, '')
86
+ const resetUrl = `${baseUrl}/admin/reset-password?token=${resetToken}`
87
+
88
+ try {
89
+ await sendPasswordResetEmail({
90
+ to: normalizedEmail,
91
+ resetUrl,
92
+ locale: emailLocale,
93
+ apiKey: env.RESEND_API_KEY,
94
+ from: env.EMAIL_FROM,
95
+ isDev: env.ENV !== 'production',
96
+ })
97
+ } catch (error) {
98
+ // Only log errors in non-production environments
99
+ if (env.ENV !== 'production') {
100
+ console.error('[password-reset] Failed to send email:', error)
101
+ }
102
+ }
103
+
104
+ return context.json({ success: true })
105
+ }
106
+