@beechcms/api 0.4.3 → 0.6.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/assets/dashboard/Searching.svg +1 -0
  2. package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
  3. package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
  4. package/assets/dashboard/index.html +2 -2
  5. package/assets/dashboard/noResult.svg +43 -0
  6. package/assets/dashboard/working.svg +1 -0
  7. package/migrations/0000_v040_base.sql +27 -0
  8. package/migrations/0030_test_seeds.sql +125 -0
  9. package/package.json +14 -9
  10. package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
  11. package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
  12. package/src/auth/bcrypt-hash-provider.ts +6 -2
  13. package/src/auth/constants.ts +4 -0
  14. package/src/auth/generate-refresh-token.test.ts +8 -4
  15. package/src/auth/hash-provider.test.ts +14 -5
  16. package/src/auth/jose-token-service.ts +7 -0
  17. package/src/auth/jwt-claims-passthrough.test.ts +87 -0
  18. package/src/auth/login.test.ts +9 -1
  19. package/src/auth/login.ts +5 -1
  20. package/src/auth/refresh.ts +7 -1
  21. package/src/auth/token-service.test.ts +9 -1
  22. package/src/factory.ts +37 -16
  23. package/src/features/automations/__tests__/action-executors.test.ts +59 -72
  24. package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
  25. package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
  26. package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
  27. package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
  28. package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
  29. package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
  30. package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
  31. package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
  32. package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
  33. package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
  34. package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
  35. package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
  36. package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
  37. package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
  38. package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
  39. package/src/features/automations/action-executors/index.ts +5 -1
  40. package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
  41. package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
  42. package/src/features/automations/action-executors/webhook.executor.ts +55 -13
  43. package/src/features/automations/automation-runner.ts +4 -0
  44. package/src/features/automations/automation-runner.utils.ts +4 -0
  45. package/src/features/automations/automations.handler.ts +4 -0
  46. package/src/features/automations/automations.schema.ts +19 -2
  47. package/src/features/automations/context-resolver.ts +4 -0
  48. package/src/features/automations/cron-runner.ts +4 -0
  49. package/src/features/automations/cron-runner.utils.ts +4 -0
  50. package/src/features/automations/filter-translation.ts +4 -0
  51. package/src/features/automations/index.ts +4 -0
  52. package/src/features/automations/template-grammar.ts +4 -0
  53. package/src/features/automations/var-access-resolver.ts +4 -0
  54. package/src/features/automations/when-evaluator.ts +4 -0
  55. package/src/features/automations/when-pushdown.ts +4 -0
  56. package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
  57. package/src/features/backrefs/backrefs.handler.ts +168 -0
  58. package/src/features/backrefs/d1-backref.repository.ts +84 -0
  59. package/src/features/backrefs/index.ts +5 -0
  60. package/src/features/content/constants.ts +6 -0
  61. package/src/features/content/handlers/bulk.handler.ts +185 -0
  62. package/src/features/content/handlers/create.ts +19 -55
  63. package/src/features/content/handlers/delete.ts +12 -37
  64. package/src/features/content/handlers/facets.ts +4 -0
  65. package/src/features/content/handlers/get.ts +4 -0
  66. package/src/features/content/handlers/helpers.test.ts +180 -0
  67. package/src/features/content/handlers/helpers.ts +93 -0
  68. package/src/features/content/handlers/list.ts +105 -19
  69. package/src/features/content/handlers/update.ts +19 -64
  70. package/src/features/content/index.ts +6 -0
  71. package/src/features/draft/draft.handler.ts +33 -8
  72. package/src/features/draft/draft.middleware.ts +4 -0
  73. package/src/features/draft/index.ts +4 -0
  74. package/src/features/email/email.provider.ts +4 -0
  75. package/src/features/email/email.service.ts +50 -45
  76. package/src/features/email/email.types.ts +12 -1
  77. package/src/features/email/index.ts +4 -0
  78. package/src/features/email/providers/resend.ts +4 -0
  79. package/src/features/email/providers/smtp.ts +55 -0
  80. package/src/features/email/templates/automation-mail.ts +4 -0
  81. package/src/features/email/templates/password-changed.ts +4 -0
  82. package/src/features/email/templates/password-reset.ts +4 -0
  83. package/src/features/email/templates/shell.ts +4 -0
  84. package/src/features/notifications/index.ts +4 -0
  85. package/src/features/notifications/notifications.handler.ts +4 -0
  86. package/src/features/password-reset/index.ts +5 -1
  87. package/src/features/password-reset/request.ts +12 -2
  88. package/src/features/password-reset/reset.ts +12 -2
  89. package/src/features/rotate-field/index.ts +4 -0
  90. package/src/features/rotate-field/rotate-field.handler.ts +4 -0
  91. package/src/features/rotate-field/rotate-field.schema.ts +4 -0
  92. package/src/features/schema/schema.handler.ts +118 -3
  93. package/src/features/seeds/index.ts +5 -0
  94. package/src/features/seeds/seeds.handler.test.ts +632 -0
  95. package/src/features/seeds/seeds.handler.ts +693 -0
  96. package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
  97. package/src/features/settings/settings.handler.ts +98 -8
  98. package/src/features/setup/index.ts +124 -11
  99. package/src/features/stats/index.ts +4 -0
  100. package/src/features/stats/stats.handler.ts +11 -21
  101. package/src/features/webhooks/index.ts +63 -0
  102. package/src/index.ts +28 -19
  103. package/src/media-utils.ts +4 -0
  104. package/src/middleware/auth-providers.middleware.ts +13 -0
  105. package/src/middleware/observability.middleware.ts +21 -3
  106. package/src/middleware/rate-limit.middleware.ts +4 -0
  107. package/src/middleware/repository.middleware.ts +25 -2
  108. package/src/middleware/seed-registry.middleware.test.ts +97 -0
  109. package/src/middleware/seed-registry.middleware.ts +21 -0
  110. package/src/middleware/storage.middleware.ts +4 -0
  111. package/src/middleware.ts +5 -7
  112. package/src/public/access-policy.ts +4 -0
  113. package/src/public/api-key-middleware.ts +4 -0
  114. package/src/public/cache-utils.ts +38 -34
  115. package/src/public/entry-projection.ts +46 -42
  116. package/src/public/idempotency.ts +23 -19
  117. package/src/public/index.ts +4 -0
  118. package/src/public/problem-details.ts +71 -0
  119. package/src/public/public-add.ts +4 -0
  120. package/src/public/public-edit.ts +4 -0
  121. package/src/public/public-errors.ts +4 -0
  122. package/src/public/public-read.ts +4 -0
  123. package/src/public/public-routes.ts +4 -0
  124. package/src/public/query-builder.test.ts +4 -0
  125. package/src/public/query-builder.ts +4 -0
  126. package/src/public/rate-limit-middleware.ts +4 -0
  127. package/src/public/read-list.ts +54 -50
  128. package/src/public/read-single.ts +48 -44
  129. package/src/public/response-builder.ts +4 -0
  130. package/src/public/sanitize.ts +4 -0
  131. package/src/public/slug-utils.ts +4 -0
  132. package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
  133. package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
  134. package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
  135. package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
  136. package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
  137. package/src/rate-limit/no-op-rate-limiter.ts +4 -0
  138. package/src/search-utils.test.ts +4 -0
  139. package/src/search-utils.ts +5 -1
  140. package/src/search.ts +5 -1
  141. package/src/shared/apply-policies.test.ts +4 -0
  142. package/src/shared/apply-policies.ts +4 -0
  143. package/src/shared/automations.repository.d1.ts +4 -0
  144. package/src/shared/background-notification-service.test.ts +4 -0
  145. package/src/shared/background-notification-service.ts +4 -0
  146. package/src/shared/base.repository.d1.ts +4 -0
  147. package/src/shared/content-utils.test.ts +4 -0
  148. package/src/shared/content-utils.ts +4 -0
  149. package/src/shared/content.repository.d1.test.ts +147 -1
  150. package/src/shared/content.repository.d1.ts +535 -66
  151. package/src/shared/d1-activity-log.repository.test.ts +4 -0
  152. package/src/shared/d1-activity-log.repository.ts +4 -0
  153. package/src/shared/d1-activity-logger.test.ts +4 -0
  154. package/src/shared/d1-activity-logger.ts +4 -0
  155. package/src/shared/d1-analytics.repository.test.ts +4 -0
  156. package/src/shared/d1-analytics.repository.ts +4 -0
  157. package/src/shared/d1-content-scan.repository.test.ts +9 -5
  158. package/src/shared/d1-content-scan.repository.ts +15 -7
  159. package/src/shared/d1-notification.repository.test.ts +4 -0
  160. package/src/shared/d1-notification.repository.ts +4 -0
  161. package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
  162. package/src/shared/d1-password-reset-token.repository.ts +4 -0
  163. package/src/shared/d1-search.repository.test.ts +4 -0
  164. package/src/shared/d1-search.repository.ts +4 -0
  165. package/src/shared/d1-session.repository.test.ts +4 -0
  166. package/src/shared/d1-session.repository.ts +4 -0
  167. package/src/shared/d1-setup-checklist.repository.ts +36 -0
  168. package/src/shared/d1-user.repository.test.ts +8 -4
  169. package/src/shared/d1-user.repository.ts +15 -5
  170. package/src/shared/d1-widget.repository.test.ts +4 -0
  171. package/src/shared/d1-widget.repository.ts +4 -0
  172. package/src/shared/demo-data-sql.ts +54 -0
  173. package/src/shared/demo-data.repository.d1.ts +20 -0
  174. package/src/shared/execution-context-scheduler.ts +4 -0
  175. package/src/shared/fixed-clock.ts +4 -0
  176. package/src/shared/fts-sync.ts +4 -0
  177. package/src/shared/idempotency.repository.d1.test.ts +4 -0
  178. package/src/shared/idempotency.repository.d1.ts +4 -0
  179. package/src/shared/in-memory-activity-logger.ts +4 -0
  180. package/src/shared/in-memory-notification-service.ts +4 -0
  181. package/src/shared/in-memory-seed.repository.ts +51 -0
  182. package/src/shared/media.repository.d1.test.ts +4 -0
  183. package/src/shared/media.repository.d1.ts +4 -0
  184. package/src/shared/qstash-notification-service.test.ts +67 -0
  185. package/src/shared/qstash-notification-service.ts +55 -0
  186. package/src/shared/query-utils.ts +4 -0
  187. package/src/shared/request-utils.ts +4 -0
  188. package/src/shared/schema-mutator.d1.ts +64 -0
  189. package/src/shared/seed-layout.repository.d1.test.ts +114 -0
  190. package/src/shared/seed-layout.repository.d1.ts +62 -0
  191. package/src/shared/seed-registry-cache.test.ts +68 -0
  192. package/src/shared/seed-registry-cache.ts +49 -0
  193. package/src/shared/seed.repository.d1.test.ts +143 -0
  194. package/src/shared/seed.repository.d1.ts +98 -0
  195. package/src/shared/sequential-id-generator.ts +11 -0
  196. package/src/shared/site-settings.repository.d1.ts +53 -0
  197. package/src/shared/storage/factory.ts +16 -15
  198. package/src/shared/storage/s3-bucket.ts +34 -2
  199. package/src/shared/storage-utils.ts +4 -0
  200. package/src/shared/system-stats.repository.d1.test.ts +4 -0
  201. package/src/shared/system-stats.repository.d1.ts +5 -1
  202. package/src/types.ts +23 -3
  203. package/src/upload.ts +134 -123
  204. package/src/widget.ts +6 -2
  205. package/assets/dashboard/assets/index-BKWnlvnV.css +0 -1
  206. package/assets/dashboard/assets/index-BMkd1Irh.js +0 -629
  207. package/src/shared/storage/r2-binding-bucket.ts +0 -81
package/src/upload.ts CHANGED
@@ -1,184 +1,195 @@
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
+
1
5
  /**
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
+ * Media Engine Single-path presigned uploads.
7
+ * Il Worker non riceve mai i bytes: agisce solo da gatekeeper (auth, validazione, firma).
6
8
  */
7
9
  import { Hono } from 'hono'
10
+ import { isMimeAccepted } from '@beechcms/core'
8
11
  import { AppEnv } from './types'
9
12
 
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
13
+ const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024
14
+ const ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024
15
+ const PRESIGN_TTL_SECONDS = 900
15
16
 
16
- type FileLike = {
17
- name: string
18
- type: string
19
- size: number
20
- arrayBuffer: () => Promise<ArrayBuffer>
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)
21
23
  }
22
24
 
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
25
  function sanitizeFilename(name: string): string {
36
26
  const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
37
27
  return base || 'file'
38
28
  }
39
29
 
40
- /** Genera chiave univoca (timestamp-sanitized-name) */
41
30
  function generateObjectKey(originalName: string): string {
42
31
  const timestamp = Math.floor(Date.now() / 1000)
43
- const sanitized = sanitizeFilename(originalName)
44
- return `${timestamp}-${sanitized}`
32
+ return `${timestamp}-${sanitizeFilename(originalName)}`
45
33
  }
46
34
 
47
- /**
48
- * Elimina oggetti dallo storage e tracciamento dal DB.
49
- */
50
35
  export async function deleteR2Objects(
51
36
  c: { var: { bucket: any, mediaRepository: any, systemStatsRepository: any } },
52
37
  objectKeys: string[]
53
38
  ): Promise<void> {
54
39
  const { bucket, mediaRepository, systemStatsRepository } = c.var
55
-
56
40
  for (const key of objectKeys) {
41
+ const media = await mediaRepository.getByKey(key).catch(() => null)
42
+ const size = media?.size_bytes ?? 0
57
43
  try {
58
- const media = await mediaRepository.getByKey(key)
59
- const size = media?.size_bytes ?? 0
60
-
61
44
  await bucket.delete(key)
45
+ } catch (err) {
46
+ console.warn(`Failed to delete storage object: ${key}`, err)
47
+ }
48
+ try {
62
49
  await mediaRepository.untrack(key)
63
-
64
- if (size > 0) {
65
- await systemStatsRepository.decrementStorage(size)
66
- }
50
+ if (size > 0) await systemStatsRepository.decrementStorage(size)
67
51
  } catch (err) {
68
- console.warn(`Failed to delete media object: ${key}`, err)
52
+ console.warn(`Failed to untrack media object: ${key}`, err)
69
53
  }
70
54
  }
71
55
  }
72
56
 
73
57
  export const uploadRoutes = new Hono<AppEnv>()
74
58
 
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
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) }
81
63
 
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
- }
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
+ }
86
70
 
87
- const formData = await c.req.formData()
88
- const fileEntry = formData.get('file')
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)
89
74
 
90
- if (!isFileLike(fileEntry)) {
91
- return c.json({ error: 'No file provided. Use field name "file"' }, 400)
92
- }
93
- const file = fileEntry
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
+ })
94
81
 
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
- }
82
+ return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200)
83
+ })
99
84
 
100
- if (file.size > MAX_FILE_SIZE_BYTES) {
101
- return c.json({ error: 'File too large. Max 5MB' }, 400)
102
- }
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
+ }
103
128
 
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
- })()
129
+ return c.json({ url: bucket.getUrl(key) }, 200)
130
+ })
126
131
 
127
- try {
128
- c.executionCtx.waitUntil(trackOperation)
129
- } catch {
130
- await trackOperation
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)
132
136
 
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
- }
137
+ const head = await c.var.bucket.head(key)
138
+ if (!head) return c.json({ error: 'Object not found' }, 404)
149
139
 
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
- }
140
+ const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS })
141
+ return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200)
155
142
  })
156
143
 
157
- /** DELETE /upload/:key - Elimina un file */
144
+ /** DELETE /upload/:key Invariato. */
158
145
  uploadRoutes.delete('/upload/:key', async (c) => {
159
146
  const key = c.req.param('key')
160
147
  if (!key) return c.json({ error: 'Missing key' }, 400)
161
-
162
148
  await deleteR2Objects(c, [decodeURIComponent(key)])
163
149
  return c.json({ success: true }, 200)
164
150
  })
165
151
 
166
- /**
167
- * Serve un file dallo storage.
168
- */
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
169
  export async function serveMediaHandler(c: any): Promise<Response> {
170
170
  const key = c.req.param('key')
171
171
  if (!key) return new Response('Missing key', { status: 400 })
172
-
173
- const bucket = c.var.bucket
174
172
  try {
175
- const object = await bucket.get(decodeURIComponent(key))
173
+ const object = await c.var.bucket.get(decodeURIComponent(key))
176
174
  if (!object) return new Response('Not found', { status: 404 })
177
-
178
175
  const headers = new Headers()
179
- headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
180
- headers.set('Cache-Control', 'public, max-age=31536000, immutable')
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
+ }
181
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')
182
193
  return new Response(object.body, { status: 200, headers })
183
194
  } catch (err) {
184
195
  console.error(`[serveMediaHandler] Error serving file ${key}:`, err)
package/src/widget.ts CHANGED
@@ -1,3 +1,7 @@
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
+
1
5
  /// <reference types="@cloudflare/workers-types" />
2
6
  import { Hono } from 'hono'
3
7
  import { deserializeFromDb } from '@beechcms/core'
@@ -28,7 +32,7 @@ function parseWindow(raw: string | undefined): TimeWindow {
28
32
  }
29
33
 
30
34
  function parseBoundedInt(raw: string | undefined, fallback: number, maximum: number, minimum = 1): number {
31
- const parsed = parseInt(raw ?? String(fallback), 10)
35
+ const parsed = Number.parseInt(raw ?? String(fallback), 10)
32
36
  if (!Number.isFinite(parsed) || parsed < minimum) return fallback
33
37
  return Math.min(parsed, maximum)
34
38
  }
@@ -125,7 +129,7 @@ widgetApp.get('/list/:seed', async (context) => {
125
129
 
126
130
  const query = context.req.query()
127
131
  const limit = parseBoundedInt(query.limit, DEFAULT_LIST_LIMIT, MAXIMUM_LIST_LIMIT)
128
- const offsetRaw = parseInt(query.offset ?? '0', 10)
132
+ const offsetRaw = Number.parseInt(query.offset ?? '0', 10)
129
133
  const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
130
134
  const search = query.search?.trim() || undefined
131
135
  const orderByColumn = query.orderBy || undefined