@beechcms/api 0.5.0 → 0.6.0-preview.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.
Files changed (225) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/Searching.svg +1 -0
  4. package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
  5. package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
  6. package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
  7. package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
  8. package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
  9. package/assets/dashboard/beechLogoDark.svg +48 -48
  10. package/assets/dashboard/index.html +18 -18
  11. package/assets/dashboard/noResult.svg +43 -0
  12. package/assets/dashboard/sol.svg +3 -3
  13. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  14. package/assets/dashboard/working.svg +1 -0
  15. package/migrations/0000_v040_base.sql +265 -238
  16. package/migrations/0029_automations.sql +14 -14
  17. package/migrations/0030_test_seeds.sql +125 -125
  18. package/package.json +13 -9
  19. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +17 -13
  20. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +22 -18
  21. package/src/auth/bcrypt-hash-provider.ts +24 -20
  22. package/src/auth/constants.ts +14 -10
  23. package/src/auth/generate-refresh-token.test.ts +23 -19
  24. package/src/auth/hash-provider.test.ts +55 -46
  25. package/src/auth/jose-token-service.ts +62 -55
  26. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  27. package/src/auth/login.test.ts +100 -92
  28. package/src/auth/login.ts +78 -74
  29. package/src/auth/refresh.ts +11 -5
  30. package/src/auth/token-service.test.ts +90 -82
  31. package/src/factory.ts +370 -347
  32. package/src/features/automations/__tests__/action-executors.test.ts +255 -268
  33. package/src/features/automations/__tests__/automation-runner.test.ts +196 -192
  34. package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -56
  35. package/src/features/automations/__tests__/automations.handler.test.ts +264 -260
  36. package/src/features/automations/__tests__/automations.repository.test.ts +163 -159
  37. package/src/features/automations/__tests__/automations.schema.test.ts +224 -134
  38. package/src/features/automations/__tests__/context-resolver.test.ts +126 -122
  39. package/src/features/automations/__tests__/cron-runner.test.ts +267 -263
  40. package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -140
  41. package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -306
  42. package/src/features/automations/__tests__/template-grammar.test.ts +308 -304
  43. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  44. package/src/features/automations/__tests__/when-evaluator.test.ts +274 -270
  45. package/src/features/automations/__tests__/when-pushdown.test.ts +281 -277
  46. package/src/features/automations/action-executors/create-entry.executor.ts +27 -23
  47. package/src/features/automations/action-executors/edit-field.executor.ts +26 -22
  48. package/src/features/automations/action-executors/index.ts +37 -33
  49. package/src/features/automations/action-executors/send-mail.executor.ts +53 -42
  50. package/src/features/automations/action-executors/set-variable.executor.ts +159 -146
  51. package/src/features/automations/action-executors/webhook.executor.ts +67 -25
  52. package/src/features/automations/automation-runner.ts +85 -81
  53. package/src/features/automations/automation-runner.utils.ts +47 -43
  54. package/src/features/automations/automations.handler.ts +197 -193
  55. package/src/features/automations/automations.schema.ts +177 -160
  56. package/src/features/automations/context-resolver.ts +152 -148
  57. package/src/features/automations/cron-runner.ts +140 -136
  58. package/src/features/automations/cron-runner.utils.ts +44 -40
  59. package/src/features/automations/filter-translation.ts +46 -42
  60. package/src/features/automations/index.ts +16 -12
  61. package/src/features/automations/template-grammar.ts +245 -241
  62. package/src/features/automations/var-access-resolver.ts +140 -136
  63. package/src/features/automations/when-evaluator.ts +87 -83
  64. package/src/features/automations/when-pushdown.ts +57 -53
  65. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  66. package/src/features/backrefs/backrefs.handler.ts +168 -0
  67. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  68. package/src/features/backrefs/index.ts +5 -0
  69. package/src/features/content/constants.ts +16 -10
  70. package/src/features/content/handlers/bulk.handler.ts +185 -0
  71. package/src/features/content/handlers/create.ts +129 -165
  72. package/src/features/content/handlers/delete.ts +62 -87
  73. package/src/features/content/handlers/facets.ts +49 -45
  74. package/src/features/content/handlers/get.ts +120 -116
  75. package/src/features/content/handlers/helpers.test.ts +180 -0
  76. package/src/features/content/handlers/helpers.ts +93 -0
  77. package/src/features/content/handlers/list.ts +174 -88
  78. package/src/features/content/handlers/update.ts +174 -219
  79. package/src/features/content/index.ts +26 -20
  80. package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
  81. package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
  82. package/src/features/dashboard-layout/index.ts +5 -0
  83. package/src/features/draft/draft.handler.ts +195 -170
  84. package/src/features/draft/draft.middleware.ts +66 -62
  85. package/src/features/draft/index.ts +5 -1
  86. package/src/features/email/email.provider.ts +42 -38
  87. package/src/features/email/email.service.ts +98 -93
  88. package/src/features/email/email.types.ts +119 -108
  89. package/src/features/email/index.ts +33 -29
  90. package/src/features/email/providers/resend.ts +67 -63
  91. package/src/features/email/providers/smtp.ts +55 -0
  92. package/src/features/email/templates/automation-mail.ts +19 -15
  93. package/src/features/email/templates/password-changed.ts +63 -59
  94. package/src/features/email/templates/password-reset.ts +68 -64
  95. package/src/features/email/templates/shell.ts +96 -92
  96. package/src/features/notifications/index.ts +5 -1
  97. package/src/features/notifications/notifications.handler.ts +105 -101
  98. package/src/features/password-reset/index.ts +19 -15
  99. package/src/features/password-reset/request.ts +92 -82
  100. package/src/features/password-reset/reset.ts +102 -92
  101. package/src/features/rotate-field/index.ts +5 -1
  102. package/src/features/rotate-field/rotate-field.handler.ts +132 -128
  103. package/src/features/rotate-field/rotate-field.schema.ts +17 -13
  104. package/src/features/schema/schema.handler.ts +131 -16
  105. package/src/features/seeds/index.ts +5 -0
  106. package/src/features/seeds/seeds.handler.test.ts +1199 -0
  107. package/src/features/seeds/seeds.handler.ts +558 -0
  108. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  109. package/src/features/settings/settings.handler.ts +392 -302
  110. package/src/features/setup/index.ts +288 -91
  111. package/src/features/stats/index.ts +5 -1
  112. package/src/features/stats/stats.handler.ts +420 -430
  113. package/src/features/webhooks/index.ts +63 -0
  114. package/src/index.ts +65 -56
  115. package/src/media-utils.ts +82 -78
  116. package/src/middleware/auth-providers.middleware.test.ts +85 -0
  117. package/src/middleware/auth-providers.middleware.ts +45 -32
  118. package/src/middleware/observability.middleware.test.ts +93 -0
  119. package/src/middleware/observability.middleware.ts +70 -52
  120. package/src/middleware/rate-limit.middleware.ts +45 -41
  121. package/src/middleware/repository.middleware.ts +117 -91
  122. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  123. package/src/middleware/seed-registry.middleware.ts +21 -0
  124. package/src/middleware/storage.middleware.ts +25 -21
  125. package/src/middleware.ts +45 -47
  126. package/src/public/access-policy.ts +27 -23
  127. package/src/public/api-key-middleware.ts +57 -53
  128. package/src/public/cache-utils.ts +38 -34
  129. package/src/public/entry-projection.ts +46 -42
  130. package/src/public/idempotency.ts +23 -19
  131. package/src/public/index.ts +16 -12
  132. package/src/public/problem-details.ts +124 -53
  133. package/src/public/public-add.ts +114 -110
  134. package/src/public/public-edit.ts +163 -159
  135. package/src/public/public-errors.ts +19 -15
  136. package/src/public/public-read.ts +63 -59
  137. package/src/public/public-routes.ts +88 -84
  138. package/src/public/query-builder.test.ts +224 -220
  139. package/src/public/query-builder.ts +156 -152
  140. package/src/public/rate-limit-middleware.ts +34 -30
  141. package/src/public/read-list.ts +54 -50
  142. package/src/public/read-single.ts +48 -44
  143. package/src/public/response-builder.ts +30 -26
  144. package/src/public/sanitize.ts +69 -65
  145. package/src/public/slug-utils.ts +18 -14
  146. package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -26
  147. package/src/rate-limit/cloudflare-rate-limiter.ts +15 -11
  148. package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -33
  149. package/src/rate-limit/in-memory-rate-limiter.ts +17 -13
  150. package/src/rate-limit/no-op-rate-limiter.test.ts +22 -18
  151. package/src/rate-limit/no-op-rate-limiter.ts +11 -7
  152. package/src/search-utils.test.ts +211 -207
  153. package/src/search-utils.ts +213 -209
  154. package/src/search.ts +65 -61
  155. package/src/shared/apply-policies.test.ts +81 -77
  156. package/src/shared/apply-policies.ts +67 -63
  157. package/src/shared/automations.repository.d1.ts +150 -146
  158. package/src/shared/background-notification-service.test.ts +62 -58
  159. package/src/shared/background-notification-service.ts +52 -48
  160. package/src/shared/base.repository.d1.ts +32 -28
  161. package/src/shared/content-utils.test.ts +165 -161
  162. package/src/shared/content-utils.ts +86 -82
  163. package/src/shared/content.repository.d1.test.ts +507 -312
  164. package/src/shared/content.repository.d1.ts +851 -382
  165. package/src/shared/d1-activity-log.repository.test.ts +140 -136
  166. package/src/shared/d1-activity-log.repository.ts +105 -101
  167. package/src/shared/d1-activity-logger.test.ts +86 -82
  168. package/src/shared/d1-activity-logger.ts +67 -63
  169. package/src/shared/d1-analytics.repository.test.ts +78 -74
  170. package/src/shared/d1-analytics.repository.ts +85 -81
  171. package/src/shared/d1-content-scan.repository.test.ts +80 -76
  172. package/src/shared/d1-content-scan.repository.ts +37 -29
  173. package/src/shared/d1-notification.repository.test.ts +128 -124
  174. package/src/shared/d1-notification.repository.ts +118 -114
  175. package/src/shared/d1-password-reset-token.repository.test.ts +81 -77
  176. package/src/shared/d1-password-reset-token.repository.ts +56 -52
  177. package/src/shared/d1-search.repository.test.ts +87 -83
  178. package/src/shared/d1-search.repository.ts +88 -84
  179. package/src/shared/d1-session.repository.test.ts +125 -121
  180. package/src/shared/d1-session.repository.ts +102 -98
  181. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  182. package/src/shared/d1-user.repository.test.ts +151 -147
  183. package/src/shared/d1-user.repository.ts +119 -109
  184. package/src/shared/d1-widget.repository.test.ts +267 -217
  185. package/src/shared/d1-widget.repository.ts +385 -337
  186. package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
  187. package/src/shared/dashboard-layout.repository.d1.ts +58 -0
  188. package/src/shared/demo-data-sql.ts +216 -0
  189. package/src/shared/demo-data.repository.d1.ts +20 -0
  190. package/src/shared/execution-context-scheduler.ts +13 -9
  191. package/src/shared/fixed-clock.ts +25 -21
  192. package/src/shared/fts-sync.ts +8 -4
  193. package/src/shared/idempotency.repository.d1.test.ts +83 -79
  194. package/src/shared/idempotency.repository.d1.ts +60 -56
  195. package/src/shared/in-memory-activity-logger.ts +19 -15
  196. package/src/shared/in-memory-notification-service.ts +19 -15
  197. package/src/shared/in-memory-seed.repository.ts +58 -0
  198. package/src/shared/media.repository.d1.test.ts +107 -103
  199. package/src/shared/media.repository.d1.ts +68 -64
  200. package/src/shared/qstash-notification-service.test.ts +67 -0
  201. package/src/shared/qstash-notification-service.ts +55 -0
  202. package/src/shared/query-utils.ts +141 -137
  203. package/src/shared/request-utils.ts +26 -22
  204. package/src/shared/schema-mutator.d1.test.ts +142 -0
  205. package/src/shared/schema-mutator.d1.ts +64 -0
  206. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  207. package/src/shared/seed-layout.repository.d1.ts +62 -0
  208. package/src/shared/seed-registry-cache.test.ts +68 -0
  209. package/src/shared/seed-registry-cache.ts +49 -0
  210. package/src/shared/seed.repository.d1.test.ts +143 -0
  211. package/src/shared/seed.repository.d1.ts +98 -0
  212. package/src/shared/sequential-id-generator.ts +33 -22
  213. package/src/shared/site-settings.repository.d1.ts +53 -0
  214. package/src/shared/storage/factory.ts +41 -40
  215. package/src/shared/storage/s3-bucket.ts +195 -163
  216. package/src/shared/storage-utils.ts +40 -36
  217. package/src/shared/system-stats.repository.d1.test.ts +58 -54
  218. package/src/shared/system-stats.repository.d1.ts +48 -44
  219. package/src/types.ts +90 -67
  220. package/src/upload.ts +198 -187
  221. package/src/widget.test.ts +85 -0
  222. package/src/widget.ts +251 -208
  223. package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
  224. package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
  225. package/src/shared/storage/r2-binding-bucket.ts +0 -81
package/src/upload.ts CHANGED
@@ -1,187 +1,198 @@
1
- /**
2
- * Media Engine: upload e servizio file gestiti tramite BeechBucket e Repository.
3
- *
4
- * Astrae lo storage (R2/S3) e il database (D1) per garantire scalabilità
5
- * e facilità di sviluppo locale.
6
- */
7
- import { Hono } from 'hono'
8
- import { AppEnv } from './types'
9
-
10
- /** Prefissi MIME consentiti (immagini e PDF) */
11
- const ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf']
12
-
13
- /** Dimensione massima file: 5 MB */
14
- const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
15
-
16
- type FileLike = {
17
- name: string
18
- type: string
19
- size: number
20
- arrayBuffer: () => Promise<ArrayBuffer>
21
- }
22
-
23
- function isFileLike(value: unknown): value is FileLike {
24
- if (!value || typeof value === 'string') return false
25
- const v = value as Record<string, unknown>
26
- return (
27
- typeof v.name === 'string' &&
28
- typeof v.type === 'string' &&
29
- typeof v.size === 'number' &&
30
- typeof v.arrayBuffer === 'function'
31
- )
32
- }
33
-
34
- /** Sanitizza il nome file */
35
- function sanitizeFilename(name: string): string {
36
- const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
37
- return base || 'file'
38
- }
39
-
40
- /** Genera chiave univoca (timestamp-sanitized-name) */
41
- function generateObjectKey(originalName: string): string {
42
- const timestamp = Math.floor(Date.now() / 1000)
43
- const sanitized = sanitizeFilename(originalName)
44
- return `${timestamp}-${sanitized}`
45
- }
46
-
47
- /**
48
- * Elimina oggetti dallo storage e tracciamento dal DB.
49
- */
50
- export async function deleteR2Objects(
51
- c: { var: { bucket: any, mediaRepository: any, systemStatsRepository: any } },
52
- objectKeys: string[]
53
- ): Promise<void> {
54
- const { bucket, mediaRepository, systemStatsRepository } = c.var
55
-
56
- for (const key of objectKeys) {
57
- try {
58
- const media = await mediaRepository.getByKey(key)
59
- const size = media?.size_bytes ?? 0
60
-
61
- await bucket.delete(key)
62
- await mediaRepository.untrack(key)
63
-
64
- if (size > 0) {
65
- await systemStatsRepository.decrementStorage(size)
66
- }
67
- } catch (err) {
68
- console.warn(`Failed to delete media object: ${key}`, err)
69
- }
70
- }
71
- }
72
-
73
- export const uploadRoutes = new Hono<AppEnv>()
74
-
75
- /** POST /upload - Carica file su BeechBucket */
76
- uploadRoutes.post('/upload', async (c) => {
77
- try {
78
- const bucket = c.var.bucket
79
- const mediaRepo = c.var.mediaRepository
80
- const statsRepo = c.var.systemStatsRepository
81
-
82
- const contentType = c.req.header('Content-Type') ?? ''
83
- if (!contentType.includes('multipart/form-data')) {
84
- return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
85
- }
86
-
87
- const formData = await c.req.formData()
88
- const fileEntry = formData.get('file')
89
-
90
- if (!isFileLike(fileEntry)) {
91
- return c.json({ error: 'No file provided. Use field name "file"' }, 400)
92
- }
93
- const file = fileEntry
94
-
95
- const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
96
- if (!mimeOk) {
97
- return c.json({ error: 'File type not allowed. Allowed: images and PDF' }, 400)
98
- }
99
-
100
- if (file.size > MAX_FILE_SIZE_BYTES) {
101
- return c.json({ error: 'File too large. Max 5MB' }, 400)
102
- }
103
-
104
- const objectKey = generateObjectKey(file.name)
105
- const body = await file.arrayBuffer()
106
-
107
- // 1. Upload allo storage
108
- await bucket.put(objectKey, body, { contentType: file.type })
109
-
110
- // 2. Aggiorna DB e Stats (in background se possibile)
111
- const uploadedBy = c.var.jwtPayload?.sub ?? ''
112
- const trackOperation = (async () => {
113
- try {
114
- await statsRepo.incrementStorage(file.size)
115
- await mediaRepo.trackUpload({
116
- key: objectKey,
117
- filename: file.name,
118
- mime_type: file.type,
119
- size_bytes: file.size,
120
- uploaded_by: uploadedBy
121
- })
122
- } catch (err) {
123
- console.error('Failed to update DB tracking for upload:', err)
124
- }
125
- })()
126
-
127
- try {
128
- c.executionCtx.waitUntil(trackOperation)
129
- } catch {
130
- await trackOperation
131
- }
132
-
133
- const publicUrl = bucket.getUrl(objectKey)
134
-
135
- const jwtPayload = c.get('jwtPayload')
136
- if (jwtPayload) {
137
- c.get('activityLogger').log({
138
- action: 'upload',
139
- entityType: 'media',
140
- entityId: objectKey,
141
- details: { name: file.name, size: file.size, type: file.type },
142
- actor: {
143
- id: jwtPayload.sub,
144
- email: jwtPayload.email ?? 'unknown',
145
- name: jwtPayload.name ?? null,
146
- },
147
- })
148
- }
149
-
150
- return c.json({ url: publicUrl }, 200)
151
- } catch (err) {
152
- console.error('Upload error:', err)
153
- return c.json({ error: 'Upload failed' }, 500)
154
- }
155
- })
156
-
157
- /** DELETE /upload/:key - Elimina un file */
158
- uploadRoutes.delete('/upload/:key', async (c) => {
159
- const key = c.req.param('key')
160
- if (!key) return c.json({ error: 'Missing key' }, 400)
161
-
162
- await deleteR2Objects(c, [decodeURIComponent(key)])
163
- return c.json({ success: true }, 200)
164
- })
165
-
166
- /**
167
- * Serve un file dallo storage.
168
- */
169
- export async function serveMediaHandler(c: any): Promise<Response> {
170
- const key = c.req.param('key')
171
- if (!key) return new Response('Missing key', { status: 400 })
172
-
173
- const bucket = c.var.bucket
174
- try {
175
- const object = await bucket.get(decodeURIComponent(key))
176
- if (!object) return new Response('Not found', { status: 404 })
177
-
178
- const headers = new Headers()
179
- headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
180
- headers.set('Cache-Control', 'public, max-age=31536000, immutable')
181
-
182
- return new Response(object.body, { status: 200, headers })
183
- } catch (err) {
184
- console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
185
- return new Response('Internal error', { status: 500 })
186
- }
187
- }
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ /**
6
+ * Media Engine — Single-path presigned uploads.
7
+ * Il Worker non riceve mai i bytes: agisce solo da gatekeeper (auth, validazione, firma).
8
+ */
9
+ import { Hono } from 'hono'
10
+ import { isMimeAccepted } from '@beechcms/core'
11
+ import { AppEnv } from './types'
12
+
13
+ const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024
14
+ const ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024
15
+ const PRESIGN_TTL_SECONDS = 900
16
+
17
+ function resolveMaxUploadBytes(env: { MAX_UPLOAD_BYTES?: string }): number {
18
+ const raw = env.MAX_UPLOAD_BYTES
19
+ if (!raw) return DEFAULT_MAX_UPLOAD_BYTES
20
+ const parsed = Number.parseInt(raw, 10)
21
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES
22
+ return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES)
23
+ }
24
+
25
+ function sanitizeFilename(name: string): string {
26
+ const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
27
+ return base || 'file'
28
+ }
29
+
30
+ function generateObjectKey(originalName: string): string {
31
+ const timestamp = Math.floor(Date.now() / 1000)
32
+ return `${timestamp}-${sanitizeFilename(originalName)}`
33
+ }
34
+
35
+ export async function deleteR2Objects(
36
+ c: { var: { bucket: any, mediaRepository: any, systemStatsRepository: any } },
37
+ objectKeys: string[]
38
+ ): Promise<void> {
39
+ const { bucket, mediaRepository, systemStatsRepository } = c.var
40
+ for (const key of objectKeys) {
41
+ const media = await mediaRepository.getByKey(key).catch(() => null)
42
+ const size = media?.size_bytes ?? 0
43
+ try {
44
+ await bucket.delete(key)
45
+ } catch (err) {
46
+ console.warn(`Failed to delete storage object: ${key}`, err)
47
+ }
48
+ try {
49
+ await mediaRepository.untrack(key)
50
+ if (size > 0) await systemStatsRepository.decrementStorage(size)
51
+ } catch (err) {
52
+ console.warn(`Failed to untrack media object: ${key}`, err)
53
+ }
54
+ }
55
+ }
56
+
57
+ export const uploadRoutes = new Hono<AppEnv>()
58
+
59
+ /** POST /upload/presign Richiede URL firmata per upload diretto a R2. */
60
+ uploadRoutes.post('/upload/presign', async (c) => {
61
+ let body: { filename?: unknown, mimeType?: unknown, sizeBytes?: unknown }
62
+ try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON body' }, 400) }
63
+
64
+ const { filename, mimeType, sizeBytes } = body
65
+ if (typeof filename !== 'string' || !filename.trim()) return c.json({ error: 'filename is required' }, 400)
66
+ if (typeof mimeType !== 'string' || !mimeType.trim()) return c.json({ error: 'mimeType is required' }, 400)
67
+ if (typeof sizeBytes !== 'number' || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
68
+ return c.json({ error: 'sizeBytes must be a positive number' }, 400)
69
+ }
70
+
71
+ const maxBytes = resolveMaxUploadBytes(c.env)
72
+ if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400)
73
+ if (!isMimeAccepted(mimeType, 'any')) return c.json({ error: 'File type not allowed' }, 400)
74
+
75
+ const key = generateObjectKey(filename)
76
+ const uploadUrl = await c.var.bucket.presignPut(key, {
77
+ expiresIn: PRESIGN_TTL_SECONDS,
78
+ contentType: mimeType,
79
+ contentLength: sizeBytes,
80
+ })
81
+
82
+ return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200)
83
+ })
84
+
85
+ /** POST /upload/confirm — Verifica l'oggetto su R2 e registra metadati. Idempotente sul key. */
86
+ uploadRoutes.post('/upload/confirm', async (c) => {
87
+ const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var
88
+
89
+ let body: { key?: unknown }
90
+ try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON body' }, 400) }
91
+ const { key } = body
92
+ if (typeof key !== 'string' || !key.trim()) return c.json({ error: 'key is required' }, 400)
93
+
94
+ const existing = await mediaRepo.getByKey(key)
95
+ if (existing) return c.json({ url: bucket.getUrl(key) }, 200)
96
+
97
+ const head = await bucket.head(key)
98
+ if (!head) return c.json({ error: 'Object not found in storage' }, 404)
99
+
100
+ const uploadedBy = c.var.jwtPayload?.sub ?? ''
101
+ const size = head.size ?? 0
102
+ const mime = head.contentType ?? 'application/octet-stream'
103
+ const filename = key.replace(/^\d+-/, '') || key
104
+
105
+ await statsRepo.incrementStorage(size)
106
+ await mediaRepo.trackUpload({
107
+ key,
108
+ filename,
109
+ mime_type: mime,
110
+ size_bytes: size,
111
+ uploaded_by: uploadedBy,
112
+ })
113
+
114
+ const jwtPayload = c.get('jwtPayload')
115
+ if (jwtPayload) {
116
+ c.get('activityLogger').log({
117
+ action: 'upload',
118
+ entityType: 'media',
119
+ entityId: key,
120
+ details: { name: filename, size, type: mime },
121
+ actor: {
122
+ id: jwtPayload.sub,
123
+ email: jwtPayload.email ?? 'unknown',
124
+ name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(' ') || null,
125
+ },
126
+ })
127
+ }
128
+
129
+ return c.json({ url: bucket.getUrl(key) }, 200)
130
+ })
131
+
132
+ /** GET /upload/download-url/:key — URL firmata di lettura per asset privati. */
133
+ uploadRoutes.get('/upload/download-url/:key', async (c) => {
134
+ const key = decodeURIComponent(c.req.param('key') ?? '')
135
+ if (!key) return c.json({ error: 'Missing key' }, 400)
136
+
137
+ const head = await c.var.bucket.head(key)
138
+ if (!head) return c.json({ error: 'Object not found' }, 404)
139
+
140
+ const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS })
141
+ return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200)
142
+ })
143
+
144
+ /** DELETE /upload/:key — Invariato. */
145
+ uploadRoutes.delete('/upload/:key', async (c) => {
146
+ const key = c.req.param('key')
147
+ if (!key) return c.json({ error: 'Missing key' }, 400)
148
+ await deleteR2Objects(c, [decodeURIComponent(key)])
149
+ return c.json({ success: true }, 200)
150
+ })
151
+
152
+ // MIME types that browsers can render as active content — must be served as
153
+ // attachments to prevent stored-XSS via same-origin script execution.
154
+ const FORCE_DOWNLOAD_MIME_PREFIXES = [
155
+ 'image/svg', // image/svg+xml, image/svg
156
+ 'text/', // text/html, text/xml, text/javascript, …
157
+ 'application/xml',
158
+ 'application/xhtml',
159
+ 'application/javascript',
160
+ 'application/x-javascript',
161
+ ]
162
+
163
+ function isActiveMimeType(mime: string): boolean {
164
+ const lower = mime.toLowerCase()
165
+ return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix))
166
+ }
167
+
168
+ /** Serve un file dallo storage (usato dalla rotta pubblica /api/media/:key). */
169
+ export async function serveMediaHandler(c: any): Promise<Response> {
170
+ const key = c.req.param('key')
171
+ if (!key) return new Response('Missing key', { status: 400 })
172
+ try {
173
+ const object = await c.var.bucket.get(decodeURIComponent(key))
174
+ if (!object) return new Response('Not found', { status: 404 })
175
+ const headers = new Headers()
176
+ const originalMime = object.contentType ?? 'application/octet-stream'
177
+
178
+ // Defense-in-depth: force attachment + opaque MIME for SVG and other
179
+ // active content types so browsers cannot render them inline even if a
180
+ // legacy object pre-dates the upload-time block in file-types.ts.
181
+ if (isActiveMimeType(originalMime)) {
182
+ headers.set('Content-Type', 'application/octet-stream')
183
+ headers.set('Content-Disposition', 'attachment')
184
+ } else {
185
+ headers.set('Content-Type', originalMime)
186
+ }
187
+
188
+ // Restrictive CSP on all media responses — even raster images don't need
189
+ // scripts or frames, and this prevents future regressions.
190
+ headers.set('Content-Security-Policy', "default-src 'none'; sandbox")
191
+ headers.set('X-Content-Type-Options', 'nosniff')
192
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable')
193
+ return new Response(object.body, { status: 200, headers })
194
+ } catch (err) {
195
+ console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
196
+ return new Response('Internal error', { status: 500 })
197
+ }
198
+ }
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: BUSL-1.1
2
+ // Copyright (c) 2024–2026 Flavio De Musso. All rights reserved.
3
+ // See LICENSE in the repository root for license terms.
4
+
5
+ import { describe, it, expect, vi } from 'vitest'
6
+ import { Hono } from 'hono'
7
+ import type { Seed, IWidgetRepository, DistributionSlice } from '@beechcms/core'
8
+ import { widgetApp } from './widget'
9
+ import type { Env, Variables } from './types'
10
+
11
+ const seed: Seed = {
12
+ slug: 'posts',
13
+ label: 'Post',
14
+ displayNameAlias: 'title',
15
+ branches: [
16
+ { id: 'br_01', alias: 'title', type: 'text', label: 'Title' },
17
+ { id: 'br_02', alias: 'status_alias', type: 'text', label: 'Status' },
18
+ ],
19
+ }
20
+
21
+ function makeRepoStub(distributionResult: DistributionSlice[] | Error = []): IWidgetRepository {
22
+ return {
23
+ aggregate: vi.fn(),
24
+ growth: vi.fn(),
25
+ leaderboard: vi.fn(),
26
+ list: vi.fn(),
27
+ timeseries: vi.fn(),
28
+ distribution: vi.fn(async () => {
29
+ if (distributionResult instanceof Error) throw distributionResult
30
+ return distributionResult
31
+ }),
32
+ } as unknown as IWidgetRepository
33
+ }
34
+
35
+ function buildApp(opts: { repo?: IWidgetRepository; seeds?: Record<string, Seed> } = {}) {
36
+ const repo = opts.repo ?? makeRepoStub()
37
+ const seeds = opts.seeds ?? { posts: seed }
38
+ const app = new Hono<{ Bindings: Env; Variables: Variables }>()
39
+ app.use('*', async (c, next) => {
40
+ c.set('widgetRepository', repo)
41
+ c.set('getSeed', (slug: string) => seeds[slug] ?? null)
42
+ await next()
43
+ })
44
+ app.route('/', widgetApp)
45
+ return { app, repo }
46
+ }
47
+
48
+ describe('GET /distribution/:seed', () => {
49
+ it('returns 200 with slices on the happy path', async () => {
50
+ const repo = makeRepoStub([{ label: 'published', value: 5 }])
51
+ const { app } = buildApp({ repo })
52
+ const res = await app.request('/distribution/posts?column=status_alias')
53
+ expect(res.status).toBe(200)
54
+ expect(await res.json()).toEqual({ slices: [{ label: 'published', value: 5 }] })
55
+ expect(repo.distribution).toHaveBeenCalledWith(seed, 'status_alias', 'all', 8)
56
+ })
57
+
58
+ it('returns 404 for an unknown seed', async () => {
59
+ const { app } = buildApp()
60
+ const res = await app.request('/distribution/ghost?column=status_alias')
61
+ expect(res.status).toBe(404)
62
+ })
63
+
64
+ it('returns 400 when column is missing', async () => {
65
+ const { app } = buildApp()
66
+ const res = await app.request('/distribution/posts')
67
+ expect(res.status).toBe(400)
68
+ })
69
+
70
+ it('returns 400 problem+json for an unsafe column', async () => {
71
+ const repo = makeRepoStub(new Error('UNSAFE_COLUMN'))
72
+ const { app } = buildApp({ repo })
73
+ const res = await app.request('/distribution/posts?column=evil')
74
+ expect(res.status).toBe(400)
75
+ const body = await res.json() as { detail: string }
76
+ expect(body.detail).toContain('Invalid column reference')
77
+ })
78
+
79
+ it('bounds limit to the maximum and applies window', async () => {
80
+ const repo = makeRepoStub([])
81
+ const { app } = buildApp({ repo })
82
+ await app.request('/distribution/posts?column=status_alias&window=week&limit=999')
83
+ expect(repo.distribution).toHaveBeenCalledWith(seed, 'status_alias', 'week', 24)
84
+ })
85
+ })