@beechcms/api 0.4.0-preview.11 → 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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -325
- package/src/features/content/constants.ts +10 -10
- package/src/features/content/handlers/create.ts +153 -163
- package/src/features/content/handlers/delete.ts +76 -85
- package/src/features/content/handlers/facets.ts +45 -45
- package/src/features/content/handlers/get.ts +116 -116
- package/src/features/content/handlers/list.ts +88 -88
- package/src/features/content/handlers/update.ts +207 -216
- package/src/features/content/index.ts +20 -20
- package/src/features/draft/draft.handler.ts +272 -272
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -267
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -24
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -18
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -166
- package/src/public/public-edit.ts +158 -158
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -216
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -152
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -28
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -382
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -45
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -41
- package/src/upload.ts +179 -331
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-9Ch2xWJr.js +0 -554
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
})
|
|
88
|
-
|
|
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', (
|
|
11
|
-
return
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
|