@beechcms/api 0.4.0 → 0.4.2
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/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- package/src/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/automations.repository.d1.ts +146 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.test.ts +76 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/execution-context-scheduler.ts +9 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -1,10 +1,32 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
|
+
import { SystemClock } from '@beechcms/core'
|
|
3
4
|
import type { Env, Variables } from '../../types'
|
|
4
5
|
import { publicProblem } from '../../public/problem-details'
|
|
5
6
|
import { cleanStr } from '../../shared/query-utils'
|
|
6
7
|
|
|
7
8
|
const DATABASE_ERROR = 'Database error'
|
|
9
|
+
const INTERNAL_SERVER_ERROR = 'Internal Server Error'
|
|
10
|
+
|
|
11
|
+
const SECONDS_PER_MINUTE = 60
|
|
12
|
+
const SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE
|
|
13
|
+
const SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR
|
|
14
|
+
const HOURS_24_IN_SECONDS = 24 * SECONDS_PER_HOUR
|
|
15
|
+
const DAYS_7_IN_SECONDS = 7 * SECONDS_PER_DAY
|
|
16
|
+
const DAYS_30_IN_SECONDS = 30 * SECONDS_PER_DAY
|
|
17
|
+
const MAX_MEDIA_SCAN = 1000
|
|
18
|
+
const DEFAULT_LIMIT = 12
|
|
19
|
+
const MAX_LIMIT = 100
|
|
20
|
+
const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
|
|
21
|
+
const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // 1M requests/month
|
|
22
|
+
|
|
23
|
+
function getMimeType(extension: string): string {
|
|
24
|
+
if (extension === 'pdf') {
|
|
25
|
+
return 'application/pdf'
|
|
26
|
+
}
|
|
27
|
+
const type = extension === 'jpg' ? 'jpeg' : (extension || 'jpeg')
|
|
28
|
+
return `image/${type}`
|
|
29
|
+
}
|
|
8
30
|
|
|
9
31
|
const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
10
32
|
|
|
@@ -14,15 +36,14 @@ const statsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
|
14
36
|
*/
|
|
15
37
|
statsApp.get('/stats/media-library', async (context) => {
|
|
16
38
|
try {
|
|
17
|
-
const { DB } = context.env
|
|
18
39
|
const mediaRepository = context.get('mediaRepository')
|
|
19
|
-
const limit = Math.min(parseInt(context.req.query('limit') ??
|
|
20
|
-
const offset = parseInt(context.req.query('offset') ?? '0')
|
|
40
|
+
const limit = Math.min(Number.parseInt(context.req.query('limit') ?? String(DEFAULT_LIMIT), 10), MAX_LIMIT)
|
|
41
|
+
const offset = Number.parseInt(context.req.query('offset') ?? '0', 10)
|
|
21
42
|
const mediaBaseUrl = (context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '')) ?? new URL(context.req.url).origin
|
|
22
43
|
|
|
23
44
|
// 1. Files tracked in the media library
|
|
24
|
-
// Take the first
|
|
25
|
-
const { items: mediaRows } = await mediaRepository.list({ limit:
|
|
45
|
+
// Take the first MAX_MEDIA_SCAN for cross-scanning
|
|
46
|
+
const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
|
|
26
47
|
|
|
27
48
|
const trackedKeys = new Set<string>()
|
|
28
49
|
const allItems: Array<{ key: string; filename: string; mime_type: string; size_bytes: number; created_at: number; url: string }> = []
|
|
@@ -33,40 +54,25 @@ statsApp.get('/stats/media-library', async (context) => {
|
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
// 2. /api/media/ URLs in the file columns of each seed (v0.4.0 — real columns)
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
trackedKeys.add(key)
|
|
56
|
-
const filename = key.replace(/^\d+-/, '')
|
|
57
|
-
const extension = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
58
|
-
const mimeType = extension === 'pdf' ? 'application/pdf' : `image/${extension === 'jpg' ? 'jpeg' : extension || 'jpeg'}`
|
|
59
|
-
const createdAt = parseInt(key.split('-')[0]) || 0
|
|
60
|
-
allItems.push({
|
|
61
|
-
key,
|
|
62
|
-
filename,
|
|
63
|
-
mime_type: mimeType,
|
|
64
|
-
size_bytes: 0,
|
|
65
|
-
created_at: createdAt,
|
|
66
|
-
url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
|
|
67
|
-
})
|
|
68
|
-
}
|
|
69
|
-
}
|
|
57
|
+
const seeds = context.get('seedRegistry').all()
|
|
58
|
+
const contentScanRepository = context.get('contentScanRepository')
|
|
59
|
+
const referencedKeysFromContent = await contentScanRepository.getReferencedMediaKeys(seeds)
|
|
60
|
+
|
|
61
|
+
for (const key of referencedKeysFromContent) {
|
|
62
|
+
if (trackedKeys.has(key)) continue
|
|
63
|
+
trackedKeys.add(key)
|
|
64
|
+
const filename = key.replace(/^\d+-/, '')
|
|
65
|
+
const extension = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
66
|
+
const mimeType = getMimeType(extension)
|
|
67
|
+
const createdAt = Number.parseInt(key.split('-')[0], 10) || 0
|
|
68
|
+
allItems.push({
|
|
69
|
+
key,
|
|
70
|
+
filename,
|
|
71
|
+
mime_type: mimeType,
|
|
72
|
+
size_bytes: 0,
|
|
73
|
+
created_at: createdAt,
|
|
74
|
+
url: `${mediaBaseUrl}/api/media/${encodeURIComponent(key)}`
|
|
75
|
+
})
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
allItems.sort((a, b) => b.created_at - a.created_at)
|
|
@@ -76,7 +82,7 @@ statsApp.get('/stats/media-library', async (context) => {
|
|
|
76
82
|
return context.json({ items: paginatedItems, total })
|
|
77
83
|
} catch (error) {
|
|
78
84
|
console.error('Media library error:', error)
|
|
79
|
-
return context.json({ error:
|
|
85
|
+
return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
|
|
80
86
|
}
|
|
81
87
|
})
|
|
82
88
|
|
|
@@ -85,40 +91,25 @@ statsApp.get('/stats/media-library', async (context) => {
|
|
|
85
91
|
*/
|
|
86
92
|
statsApp.get('/stats/unused-media', async (context) => {
|
|
87
93
|
try {
|
|
88
|
-
const { DB } = context.env
|
|
89
94
|
const mediaRepository = context.get('mediaRepository')
|
|
90
|
-
const seeds =
|
|
95
|
+
const seeds = context.get('seedRegistry').all()
|
|
91
96
|
|
|
92
97
|
// All tracked media keys
|
|
93
|
-
const { items: mediaRows } = await mediaRepository.list({ limit:
|
|
98
|
+
const { items: mediaRows } = await mediaRepository.list({ limit: MAX_MEDIA_SCAN, offset: 0 })
|
|
94
99
|
|
|
95
100
|
if (mediaRows.length === 0) {
|
|
96
101
|
return context.json({ items: [] })
|
|
97
102
|
}
|
|
98
103
|
|
|
99
104
|
// Collect all referenced keys in the file columns of each seed
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
const fileBranches = seed.branches.filter(branch => branch.type === 'file')
|
|
103
|
-
if (fileBranches.length === 0) continue
|
|
104
|
-
const columns = fileBranches.map(branch => branch.alias).join(', ')
|
|
105
|
-
const rows = await DB.prepare(
|
|
106
|
-
`SELECT ${columns} FROM content_${seed.slug}`
|
|
107
|
-
).all<Record<string, string | null>>()
|
|
108
|
-
|
|
109
|
-
for (const row of rows.results ?? []) {
|
|
110
|
-
const combinedValues = Object.values(row).filter(Boolean).join(' ')
|
|
111
|
-
for (const match of combinedValues.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
|
|
112
|
-
referencedKeys.add(decodeURIComponent(match[1]))
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
105
|
+
const contentScanRepository = context.get('contentScanRepository')
|
|
106
|
+
const referencedKeys = await contentScanRepository.getReferencedMediaKeys(seeds)
|
|
116
107
|
|
|
117
108
|
const unusedMedia = mediaRows.filter(mediaItem => !referencedKeys.has(mediaItem.key))
|
|
118
109
|
return context.json({ items: unusedMedia })
|
|
119
110
|
} catch (error) {
|
|
120
111
|
console.error('Unused media error:', error)
|
|
121
|
-
return context.json({ error:
|
|
112
|
+
return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
|
|
122
113
|
}
|
|
123
114
|
})
|
|
124
115
|
|
|
@@ -128,7 +119,7 @@ statsApp.get('/stats/unused-media', async (context) => {
|
|
|
128
119
|
statsApp.get('/stats/setup-checklist', async (context) => {
|
|
129
120
|
try {
|
|
130
121
|
const { DB } = context.env
|
|
131
|
-
const seeds =
|
|
122
|
+
const seeds = context.get('seedRegistry').all()
|
|
132
123
|
|
|
133
124
|
// 1. System tables present
|
|
134
125
|
const systemTableNames = [
|
|
@@ -191,41 +182,38 @@ statsApp.get('/stats/setup-checklist', async (context) => {
|
|
|
191
182
|
*/
|
|
192
183
|
statsApp.get('/stats/total', async (context) => {
|
|
193
184
|
try {
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
const thirtyDaysAgo = now - (30 * 24 * 60 * 60)
|
|
185
|
+
const now = SystemClock.nowSeconds()
|
|
186
|
+
const twentyFourHoursAgo = now - HOURS_24_IN_SECONDS
|
|
187
|
+
const sevenDaysAgo = now - DAYS_7_IN_SECONDS
|
|
188
|
+
const thirtyDaysAgo = now - DAYS_30_IN_SECONDS
|
|
199
189
|
|
|
200
190
|
// Total: SUM of per-seed counts (current live entries)
|
|
201
|
-
const seeds =
|
|
191
|
+
const seeds = context.get('seedRegistry').all()
|
|
192
|
+
const widgetRepository = context.get('widgetRepository')
|
|
202
193
|
const countResults = await Promise.all(
|
|
203
|
-
seeds.map(seed =>
|
|
194
|
+
seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
|
|
204
195
|
)
|
|
205
|
-
const totalEntriesCount = countResults.reduce((accumulator,
|
|
196
|
+
const totalEntriesCount = countResults.reduce((accumulator, count) => accumulator + count, 0)
|
|
206
197
|
|
|
207
198
|
// today/week/month: create events in activity_logs (entity_type = 'content')
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
215
|
-
.bind(twentyFourHoursAgo, sevenDaysAgo, thirtyDaysAgo)
|
|
216
|
-
.first<{ today: number; week: number; month: number }>()
|
|
199
|
+
const activityLogRepository = context.get('activityLogRepository')
|
|
200
|
+
const [todayCount, weekCount, monthCount] = await Promise.all([
|
|
201
|
+
activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: twentyFourHoursAgo }),
|
|
202
|
+
activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: sevenDaysAgo }),
|
|
203
|
+
activityLogRepository.countSince({ action: 'create', entityType: 'content', sinceTimestamp: thirtyDaysAgo }),
|
|
204
|
+
])
|
|
217
205
|
|
|
218
206
|
return context.json({
|
|
219
207
|
total: totalEntriesCount,
|
|
220
|
-
today:
|
|
221
|
-
week:
|
|
222
|
-
month:
|
|
208
|
+
today: todayCount,
|
|
209
|
+
week: weekCount,
|
|
210
|
+
month: monthCount,
|
|
223
211
|
})
|
|
224
212
|
} catch (error) {
|
|
225
213
|
console.error('Content stats error:', error)
|
|
226
214
|
return publicProblem(context, {
|
|
227
215
|
type: 'content-database-error',
|
|
228
|
-
title:
|
|
216
|
+
title: INTERNAL_SERVER_ERROR,
|
|
229
217
|
status: 500,
|
|
230
218
|
detail: DATABASE_ERROR,
|
|
231
219
|
})
|
|
@@ -240,25 +228,27 @@ statsApp.get('/stats/total', async (context) => {
|
|
|
240
228
|
*/
|
|
241
229
|
statsApp.get('/stats/recent-activity', async (context) => {
|
|
242
230
|
try {
|
|
243
|
-
const { DB } = context.env
|
|
244
231
|
const slug = cleanStr(context.req.query('slug'))
|
|
245
232
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (slug) {
|
|
251
|
-
query += ' WHERE entity_slug = ?'
|
|
252
|
-
queryParameters.push(slug)
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
query += ' ORDER BY created_at DESC LIMIT 15'
|
|
256
|
-
|
|
257
|
-
const result = await DB.prepare(query).bind(...queryParameters).all()
|
|
233
|
+
const entries = await context.get('activityLogRepository').list({
|
|
234
|
+
entitySlug: slug ?? undefined,
|
|
235
|
+
limit: 15,
|
|
236
|
+
})
|
|
258
237
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
238
|
+
// Preserve the legacy snake_case wire format consumed by the dashboard
|
|
239
|
+
// recent-activity widget. The repository returns camelCase records,
|
|
240
|
+
// so we reshape only on the way out.
|
|
241
|
+
const activities = entries.map((entry) => ({
|
|
242
|
+
id: entry.id,
|
|
243
|
+
user_id: entry.userId,
|
|
244
|
+
user_email: entry.userEmail,
|
|
245
|
+
user_name: entry.userName,
|
|
246
|
+
action: entry.action,
|
|
247
|
+
entity_type: entry.entityType,
|
|
248
|
+
entity_id: entry.entityId,
|
|
249
|
+
entity_slug: entry.entitySlug,
|
|
250
|
+
details: entry.details,
|
|
251
|
+
created_at: entry.createdAt,
|
|
262
252
|
}))
|
|
263
253
|
|
|
264
254
|
context.header('Cache-Control', 'no-store')
|
|
@@ -268,7 +258,7 @@ statsApp.get('/stats/recent-activity', async (context) => {
|
|
|
268
258
|
console.error('Recent activity error:', error)
|
|
269
259
|
return publicProblem(context, {
|
|
270
260
|
type: 'content-database-error',
|
|
271
|
-
title:
|
|
261
|
+
title: INTERNAL_SERVER_ERROR,
|
|
272
262
|
status: 500,
|
|
273
263
|
detail: DATABASE_ERROR,
|
|
274
264
|
})
|
|
@@ -280,7 +270,6 @@ statsApp.get('/stats/recent-activity', async (context) => {
|
|
|
280
270
|
*/
|
|
281
271
|
statsApp.get('/stats/health', async (context) => {
|
|
282
272
|
try {
|
|
283
|
-
const { DB } = context.env
|
|
284
273
|
const systemStatsRepository = context.get('systemStatsRepository')
|
|
285
274
|
|
|
286
275
|
// 1. Retrieve storage usage from repository (system_stats)
|
|
@@ -292,16 +281,10 @@ statsApp.get('/stats/health', async (context) => {
|
|
|
292
281
|
}
|
|
293
282
|
|
|
294
283
|
// 2. Aggregate D1 requests (proxy for database health) - last 30 days
|
|
295
|
-
const thirtyDaysAgo =
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const totalRequests = d1Stats?.total_requests ?? 0
|
|
301
|
-
|
|
302
|
-
// 3. Define limits (Cloudflare Free Tier as reference)
|
|
303
|
-
const R2_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024 // 10GB
|
|
304
|
-
const D1_MONTHLY_REQUESTS_LIMIT = 1000000 // Simulate a limit of 1M requests/month
|
|
284
|
+
const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
|
|
285
|
+
const totalRequests = await context
|
|
286
|
+
.get('analyticsRepository')
|
|
287
|
+
.sumByMetric('requests', '', thirtyDaysAgo)
|
|
305
288
|
|
|
306
289
|
const storagePercentage = Math.min(Math.round((storageUsedBytes / R2_STORAGE_LIMIT) * 1000) / 10, 100)
|
|
307
290
|
const d1Percentage = Math.min(Math.round((totalRequests / D1_MONTHLY_REQUESTS_LIMIT) * 1000) / 10, 100)
|
|
@@ -318,7 +301,7 @@ statsApp.get('/stats/health', async (context) => {
|
|
|
318
301
|
percentage: d1Percentage
|
|
319
302
|
},
|
|
320
303
|
status: (storagePercentage < 90 && d1Percentage < 90) ? 'healthy' : 'warning',
|
|
321
|
-
lastUpdate:
|
|
304
|
+
lastUpdate: SystemClock.nowSeconds()
|
|
322
305
|
})
|
|
323
306
|
} catch (error) {
|
|
324
307
|
console.error('System health stats error:', error)
|
|
@@ -331,28 +314,17 @@ statsApp.get('/stats/health', async (context) => {
|
|
|
331
314
|
*/
|
|
332
315
|
statsApp.get('/stats/cloudflare', async (context) => {
|
|
333
316
|
try {
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
// Retrieve sum of metrics in the last 30 days
|
|
338
|
-
const metricsResult = await DB.prepare(
|
|
339
|
-
`SELECT
|
|
340
|
-
metric,
|
|
341
|
-
SUM(value) as total_value
|
|
342
|
-
FROM analytics
|
|
343
|
-
WHERE day_ts >= ? AND seed = ''
|
|
344
|
-
GROUP BY metric`
|
|
345
|
-
)
|
|
346
|
-
.bind(thirtyDaysAgo)
|
|
347
|
-
.all<{ metric: string; total_value: number }>()
|
|
317
|
+
const thirtyDaysAgo = SystemClock.nowSeconds() - DAYS_30_IN_SECONDS
|
|
318
|
+
const analyticsRepository = context.get('analyticsRepository')
|
|
348
319
|
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
320
|
+
const [recordedRequests, recordedVisitors] = await Promise.all([
|
|
321
|
+
analyticsRepository.sumByMetric('requests', '', thirtyDaysAgo),
|
|
322
|
+
analyticsRepository.sumByMetric('visitors', '', thirtyDaysAgo),
|
|
323
|
+
])
|
|
352
324
|
|
|
353
325
|
// Simulate some Cloudflare metrics not directly tracked for a premium feel
|
|
354
|
-
const requestsCount =
|
|
355
|
-
const visitorsCount =
|
|
326
|
+
const requestsCount = recordedRequests > 0 ? recordedRequests : Math.floor(Math.random() * 5000) + 1000
|
|
327
|
+
const visitorsCount = recordedVisitors > 0 ? recordedVisitors : Math.floor(requestsCount / 12) + 1
|
|
356
328
|
const bandwidthMB = Math.round((requestsCount * 0.15) * 10) / 10
|
|
357
329
|
|
|
358
330
|
const systemStatsRepository = context.get('systemStatsRepository')
|
|
@@ -400,7 +372,7 @@ statsApp.get('/stats/cloudflare', async (context) => {
|
|
|
400
372
|
console.error('Cloudflare stats error:', error)
|
|
401
373
|
return publicProblem(context, {
|
|
402
374
|
type: 'content-database-error',
|
|
403
|
-
title:
|
|
375
|
+
title: INTERNAL_SERVER_ERROR,
|
|
404
376
|
status: 500,
|
|
405
377
|
detail: DATABASE_ERROR,
|
|
406
378
|
})
|
|
@@ -412,17 +384,17 @@ statsApp.get('/stats/cloudflare', async (context) => {
|
|
|
412
384
|
*/
|
|
413
385
|
statsApp.get('/stats/breakdown', async (context) => {
|
|
414
386
|
try {
|
|
415
|
-
const
|
|
416
|
-
const seeds = Object.values(context.get('seedRegistry'))
|
|
387
|
+
const seeds = context.get('seedRegistry').all()
|
|
417
388
|
|
|
389
|
+
const widgetRepository = context.get('widgetRepository')
|
|
418
390
|
const counts = await Promise.all(
|
|
419
|
-
seeds.map(seed =>
|
|
391
|
+
seeds.map(seed => widgetRepository.aggregate(seed, { op: 'count' }, 'all'))
|
|
420
392
|
)
|
|
421
|
-
|
|
393
|
+
|
|
422
394
|
const breakdown = seeds.map((seed, index) => ({
|
|
423
395
|
slug: seed.slug,
|
|
424
396
|
label: seed.labelPlural || seed.label,
|
|
425
|
-
count: counts[index]
|
|
397
|
+
count: counts[index] ?? 0,
|
|
426
398
|
}))
|
|
427
399
|
|
|
428
400
|
return context.json(breakdown)
|
|
@@ -430,7 +402,7 @@ statsApp.get('/stats/breakdown', async (context) => {
|
|
|
430
402
|
console.error('Breakdown stats error:', error)
|
|
431
403
|
return publicProblem(context, {
|
|
432
404
|
type: 'content-database-error',
|
|
433
|
-
title:
|
|
405
|
+
title: INTERNAL_SERVER_ERROR,
|
|
434
406
|
status: 500,
|
|
435
407
|
detail: DATABASE_ERROR,
|
|
436
408
|
})
|
|
@@ -451,7 +423,7 @@ statsApp.post('/stats/storage/sync', async (context) => {
|
|
|
451
423
|
return context.json({ success: true, size: realSize })
|
|
452
424
|
} catch (error) {
|
|
453
425
|
console.error('Storage sync error:', error)
|
|
454
|
-
return context.json({ error:
|
|
426
|
+
return context.json({ error: INTERNAL_SERVER_ERROR }, 500)
|
|
455
427
|
}
|
|
456
428
|
})
|
|
457
429
|
|
package/src/index.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { createBeechApp } from './factory'
|
|
2
|
+
import { SeedRegistry, SystemIdGenerator } from '@beechcms/core'
|
|
3
|
+
import { runCronAutomations } from './features/automations'
|
|
4
|
+
import { D1AutomationRepository } from './shared/automations.repository.d1'
|
|
5
|
+
import { D1ContentRepository } from './shared/content.repository.d1'
|
|
6
|
+
import type { Env } from './types'
|
|
2
7
|
|
|
3
|
-
|
|
4
|
-
* Entry point per lo sviluppo locale del monorepo.
|
|
5
|
-
* Carica dinamicamente seed.ts o seeds.ts dalla root di apps/api se presenti.
|
|
6
|
-
*/
|
|
7
|
-
let seeds: any = []
|
|
8
|
+
let seeds: any[] = []
|
|
8
9
|
|
|
9
10
|
try {
|
|
10
11
|
// @ts-ignore
|
|
11
12
|
const mod = await import('../seed.ts')
|
|
12
13
|
const registry = mod.default || mod.SEED_REGISTRY || mod
|
|
13
|
-
seeds = (typeof registry === 'object' && !Array.isArray(registry))
|
|
14
|
-
? Object.values(registry)
|
|
14
|
+
seeds = (typeof registry === 'object' && !Array.isArray(registry))
|
|
15
|
+
? Object.values(registry)
|
|
15
16
|
: registry
|
|
16
17
|
} catch (e) {
|
|
17
18
|
// Fallback se seed.ts non esiste
|
|
@@ -21,4 +22,35 @@ const app = createBeechApp({ seeds })
|
|
|
21
22
|
|
|
22
23
|
app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
const validSeeds = seeds.filter((s: any) => s && typeof s === 'object' && 'slug' in s)
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
fetch: app.fetch,
|
|
29
|
+
|
|
30
|
+
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
|
|
31
|
+
const scheduledTime = controller?.scheduledTime ?? Date.now()
|
|
32
|
+
|
|
33
|
+
if (!env.DB) {
|
|
34
|
+
console.warn('[cron] D1 binding missing. Skipping cron automations.')
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const automationRepository = new D1AutomationRepository(env.DB)
|
|
39
|
+
const contentRepository = new D1ContentRepository(env.DB)
|
|
40
|
+
const registry = new SeedRegistry(validSeeds)
|
|
41
|
+
const getSeed = (slug: string) => registry.get(slug) ?? null
|
|
42
|
+
|
|
43
|
+
ctx.waitUntil(
|
|
44
|
+
runCronAutomations(
|
|
45
|
+
{
|
|
46
|
+
automationRepository,
|
|
47
|
+
contentRepository,
|
|
48
|
+
getSeed,
|
|
49
|
+
env: env as unknown as Record<string, string | undefined>,
|
|
50
|
+
idGenerator: SystemIdGenerator,
|
|
51
|
+
},
|
|
52
|
+
scheduledTime,
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
},
|
|
56
|
+
} satisfies ExportedHandler<Env>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
|
|
3
|
+
import { SystemClock } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv } from '../types'
|
|
5
|
+
import { BcryptHashProvider } from '../auth/bcrypt-hash-provider'
|
|
6
|
+
import { JoseTokenService } from '../auth/jose-token-service'
|
|
7
|
+
|
|
8
|
+
export interface AuthProviderOverrides {
|
|
9
|
+
hashProvider?: IHashProvider
|
|
10
|
+
tokenService?: ITokenService
|
|
11
|
+
clock?: IClock
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
|
|
15
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
16
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
17
|
+
const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
|
|
18
|
+
const tokenService = overrides?.tokenService ?? new JoseTokenService(
|
|
19
|
+
context.env.JWT_SECRET,
|
|
20
|
+
{
|
|
21
|
+
issuer: context.env.JWT_ISSUER,
|
|
22
|
+
audience: context.env.JWT_AUDIENCE,
|
|
23
|
+
},
|
|
24
|
+
resolvedClock,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
context.set('hashProvider', hashProvider)
|
|
28
|
+
context.set('tokenService', tokenService)
|
|
29
|
+
|
|
30
|
+
await next()
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import type { IActivityLogger, INotificationService, IClock, IIdGenerator } from '@beechcms/core'
|
|
3
|
+
import { SystemClock, SystemIdGenerator } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv } from '../types'
|
|
5
|
+
import { D1ActivityLogger } from '../shared/d1-activity-logger'
|
|
6
|
+
import { BackgroundNotificationService } from '../shared/background-notification-service'
|
|
7
|
+
|
|
8
|
+
export interface ObservabilityOverrides {
|
|
9
|
+
activityLogger?: IActivityLogger
|
|
10
|
+
notificationService?: INotificationService
|
|
11
|
+
clock?: IClock
|
|
12
|
+
idGenerator?: IIdGenerator
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Observability middleware.
|
|
17
|
+
*
|
|
18
|
+
* Injects the activity logger and the notification service into the Hono
|
|
19
|
+
* context. Both depend on `executionCtx.waitUntil` to fire-and-forget their
|
|
20
|
+
* persistence work in production. The notification service additionally
|
|
21
|
+
* depends on the notification repository — `repositoryMiddleware` MUST run
|
|
22
|
+
* before this middleware in the factory pipeline.
|
|
23
|
+
*
|
|
24
|
+
* Tests can pass their own implementations via `overrides` to bypass D1.
|
|
25
|
+
*/
|
|
26
|
+
export const observabilityMiddleware = (overrides?: ObservabilityOverrides) => {
|
|
27
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
28
|
+
let scheduleBackgroundTask: ((task: Promise<unknown>) => void) | undefined
|
|
29
|
+
try {
|
|
30
|
+
const executionContext = context.executionCtx
|
|
31
|
+
scheduleBackgroundTask = executionContext.waitUntil.bind(executionContext)
|
|
32
|
+
} catch {
|
|
33
|
+
scheduleBackgroundTask = undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
37
|
+
const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
|
|
38
|
+
|
|
39
|
+
const activityLogger =
|
|
40
|
+
overrides?.activityLogger ??
|
|
41
|
+
new D1ActivityLogger(context.env.DB, resolvedClock, resolvedIdGenerator, scheduleBackgroundTask)
|
|
42
|
+
|
|
43
|
+
const notificationService =
|
|
44
|
+
overrides?.notificationService ??
|
|
45
|
+
new BackgroundNotificationService(context.get('notificationRepository'), scheduleBackgroundTask)
|
|
46
|
+
|
|
47
|
+
context.set('activityLogger', activityLogger)
|
|
48
|
+
context.set('notificationService', notificationService)
|
|
49
|
+
|
|
50
|
+
await next()
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { createMiddleware } from 'hono/factory'
|
|
3
|
+
import type { IRateLimiter } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv, Env } from '../types'
|
|
5
|
+
import { CloudflareRateLimiter } from '../rate-limit/cloudflare-rate-limiter'
|
|
6
|
+
import { NoOpRateLimiter } from '../rate-limit/no-op-rate-limiter'
|
|
7
|
+
|
|
8
|
+
export type RateLimiterName =
|
|
9
|
+
| 'login'
|
|
10
|
+
| 'tokenRefresh'
|
|
11
|
+
| 'forgotPassword'
|
|
12
|
+
| 'resetPassword'
|
|
13
|
+
| 'publicApiRead'
|
|
14
|
+
| 'publicApiWrite'
|
|
15
|
+
|
|
16
|
+
export interface IRateLimiterRegistry {
|
|
17
|
+
getLimiter(name: RateLimiterName): IRateLimiter
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NO_OP = new NoOpRateLimiter()
|
|
21
|
+
|
|
22
|
+
function buildDefaultRegistry(env: Env): IRateLimiterRegistry {
|
|
23
|
+
const limiters: Record<RateLimiterName, IRateLimiter> = {
|
|
24
|
+
login: env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
|
|
25
|
+
tokenRefresh: env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
|
|
26
|
+
forgotPassword: env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
|
|
27
|
+
resetPassword: env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
|
|
28
|
+
publicApiRead: env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
|
|
29
|
+
publicApiWrite: env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { getLimiter: (name) => limiters[name] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const rateLimiterMiddleware = (overrides?: { registry?: IRateLimiterRegistry }) => {
|
|
36
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
37
|
+
const registry = overrides?.registry ?? buildDefaultRegistry(context.env)
|
|
38
|
+
context.set('rateLimiters', registry)
|
|
39
|
+
await next()
|
|
40
|
+
})
|
|
41
|
+
}
|