@beechcms/api 0.4.0-preview.9 → 0.4.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 (140) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  4. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/bcrypt-hash-provider.ts +20 -0
  12. package/src/auth/constants.ts +10 -10
  13. package/src/auth/generate-refresh-token.test.ts +19 -0
  14. package/src/auth/hash-provider.test.ts +46 -0
  15. package/src/auth/in-memory-hash-provider.ts +13 -0
  16. package/src/auth/jose-token-service.ts +55 -0
  17. package/src/auth/login.test.ts +92 -0
  18. package/src/auth/login.ts +74 -91
  19. package/src/auth/refresh.ts +5 -127
  20. package/src/auth/static-token-service.ts +18 -0
  21. package/src/auth/token-service.test.ts +82 -0
  22. package/src/factory.ts +339 -303
  23. package/src/features/content/constants.ts +10 -0
  24. package/src/features/content/handlers/create.ts +157 -0
  25. package/src/features/content/handlers/delete.ts +80 -0
  26. package/src/features/content/handlers/facets.ts +45 -0
  27. package/src/features/content/handlers/get.ts +116 -0
  28. package/src/features/content/handlers/list.ts +88 -0
  29. package/src/features/content/handlers/update.ts +211 -0
  30. package/src/features/content/index.ts +20 -0
  31. package/src/features/draft/draft.handler.ts +283 -198
  32. package/src/features/draft/index.ts +1 -1
  33. package/src/features/email/email.provider.ts +38 -38
  34. package/src/features/email/email.service.ts +80 -80
  35. package/src/features/email/email.types.ts +98 -98
  36. package/src/features/email/index.ts +28 -28
  37. package/src/features/email/providers/resend.ts +63 -63
  38. package/src/features/email/templates/password-changed.ts +59 -59
  39. package/src/features/email/templates/password-reset.ts +64 -64
  40. package/src/features/email/templates/shell.ts +92 -93
  41. package/src/features/notifications/index.ts +1 -1
  42. package/src/features/notifications/notifications.handler.ts +101 -88
  43. package/src/features/password-reset/index.ts +15 -15
  44. package/src/features/password-reset/request.ts +82 -88
  45. package/src/features/password-reset/reset.ts +92 -110
  46. package/src/features/rotate-field/index.ts +1 -1
  47. package/src/features/rotate-field/rotate-field.handler.ts +128 -82
  48. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  49. package/src/features/schema/schema.handler.ts +16 -16
  50. package/src/features/settings/settings.handler.ts +301 -249
  51. package/src/features/setup/index.ts +91 -59
  52. package/src/features/stats/index.ts +1 -1
  53. package/src/features/stats/stats.handler.ts +430 -395
  54. package/src/index.ts +24 -11
  55. package/src/media-utils.ts +78 -78
  56. package/src/middleware/auth-providers.middleware.ts +32 -0
  57. package/src/middleware/observability.middleware.ts +52 -0
  58. package/src/middleware/rate-limit.middleware.ts +41 -0
  59. package/src/middleware/repository.middleware.ts +60 -0
  60. package/src/middleware/storage.middleware.ts +21 -0
  61. package/src/middleware.ts +47 -67
  62. package/src/public/access-policy.ts +23 -23
  63. package/src/public/api-key-middleware.ts +53 -53
  64. package/src/public/index.ts +12 -12
  65. package/src/public/problem-details.ts +48 -42
  66. package/src/public/public-add.ts +156 -183
  67. package/src/public/public-edit.ts +159 -183
  68. package/src/public/public-errors.ts +15 -15
  69. package/src/public/public-read.ts +216 -217
  70. package/src/public/public-routes.ts +84 -31
  71. package/src/public/query-builder.test.ts +220 -0
  72. package/src/public/query-builder.ts +152 -241
  73. package/src/public/rate-limit-middleware.ts +30 -42
  74. package/src/public/response-builder.ts +26 -26
  75. package/src/public/sanitize.ts +65 -65
  76. package/src/public/slug-utils.ts +14 -14
  77. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  78. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  79. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  80. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  81. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  82. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  83. package/src/search-utils.test.ts +207 -0
  84. package/src/search-utils.ts +209 -192
  85. package/src/search.ts +61 -72
  86. package/src/shared/apply-policies.test.ts +77 -0
  87. package/src/shared/apply-policies.ts +63 -63
  88. package/src/shared/background-notification-service.test.ts +58 -0
  89. package/src/shared/background-notification-service.ts +48 -0
  90. package/src/shared/base.repository.d1.ts +28 -0
  91. package/src/shared/content-utils.test.ts +161 -0
  92. package/src/shared/content-utils.ts +82 -108
  93. package/src/shared/content.repository.d1.test.ts +312 -0
  94. package/src/shared/content.repository.d1.ts +382 -0
  95. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  96. package/src/shared/d1-activity-log.repository.ts +101 -0
  97. package/src/shared/d1-activity-logger.test.ts +82 -0
  98. package/src/shared/d1-activity-logger.ts +63 -0
  99. package/src/shared/d1-analytics.repository.test.ts +74 -0
  100. package/src/shared/d1-analytics.repository.ts +81 -0
  101. package/src/shared/d1-content-scan.repository.ts +29 -0
  102. package/src/shared/d1-notification.repository.test.ts +124 -0
  103. package/src/shared/d1-notification.repository.ts +114 -0
  104. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  105. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  106. package/src/shared/d1-search.repository.test.ts +83 -0
  107. package/src/shared/d1-search.repository.ts +84 -0
  108. package/src/shared/d1-session.repository.test.ts +121 -0
  109. package/src/shared/d1-session.repository.ts +98 -0
  110. package/src/shared/d1-user.repository.test.ts +147 -0
  111. package/src/shared/d1-user.repository.ts +109 -0
  112. package/src/shared/d1-widget.repository.test.ts +217 -0
  113. package/src/shared/d1-widget.repository.ts +337 -0
  114. package/src/shared/fixed-clock.ts +21 -0
  115. package/src/shared/fts-sync.ts +4 -4
  116. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  117. package/src/shared/idempotency.repository.d1.ts +56 -0
  118. package/src/shared/in-memory-activity-logger.ts +15 -0
  119. package/src/shared/in-memory-notification-service.ts +15 -0
  120. package/src/shared/media.repository.d1.test.ts +103 -0
  121. package/src/shared/media.repository.d1.ts +64 -0
  122. package/src/shared/query-utils.ts +137 -137
  123. package/src/shared/request-utils.ts +22 -0
  124. package/src/shared/sequential-id-generator.ts +22 -0
  125. package/src/shared/storage/factory.ts +40 -0
  126. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  127. package/src/shared/storage/s3-bucket.ts +163 -0
  128. package/src/shared/storage-utils.ts +36 -36
  129. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  130. package/src/shared/system-stats.repository.d1.ts +44 -0
  131. package/src/types.ts +63 -36
  132. package/src/upload.ts +186 -335
  133. package/src/widget.ts +208 -349
  134. package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
  135. package/assets/dashboard/assets/index-CQODXprH.css +0 -1
  136. package/src/content.ts +0 -502
  137. package/src/features/draft/draft.test.ts +0 -315
  138. package/src/features/rotate-field/rotate-field.test.ts +0 -297
  139. package/src/shared/activity-logger.ts +0 -79
  140. package/src/shared/notification-service.ts +0 -56
package/src/upload.ts CHANGED
@@ -1,335 +1,186 @@
1
- /**
2
- * Media Engine: upload e servizio file da Cloudflare R2.
3
- *
4
- * Usa l'API S3-compatibile (@aws-sdk/client-s3) con chiavi di accesso per
5
- * portabilità e configurabilità. Le credenziali vanno in .dev.vars (locale)
6
- * o wrangler secret (produzione).
7
- *
8
- * @see docs/media-engine.md
9
- */
10
- /// <reference types="@cloudflare/workers-types" />
11
- import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
12
- import { Hono } from 'hono'
13
- import { authMiddleware } from './middleware'
14
- import { logActivity } from './shared/activity-logger'
15
-
16
- /** Variabili d'ambiente per upload e media (R2 via S3 API) */
17
- type UploadBindings = {
18
- JWT_SECRET: string
19
- MEDIA_BASE_URL?: string
20
- ENV?: string
21
- R2_ACCESS_KEY_ID?: string
22
- R2_SECRET_ACCESS_KEY?: string
23
- R2_ENDPOINT?: string
24
- R2_BUCKET_NAME?: string
25
- DB: D1Database
26
- }
27
-
28
- type Variables = {
29
- jwtPayload: { sub: string; email?: string }
30
- }
31
-
32
- /** Prefissi MIME consentiti (immagini e PDF) */
33
- const ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf']
34
-
35
- /** Dimensione massima file: 5 MB */
36
- const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
37
-
38
- type FileLike = {
39
- name: string
40
- type: string
41
- size: number
42
- arrayBuffer: () => Promise<ArrayBuffer>
43
- }
44
-
45
- function isFileLike(value: unknown): value is FileLike {
46
- if (!value || typeof value === 'string') return false
47
- const v = value as Record<string, unknown>
48
-
49
- return (
50
- typeof v.name === 'string' &&
51
- typeof v.type === 'string' &&
52
- typeof v.size === 'number' &&
53
- typeof v.arrayBuffer === 'function'
54
- )
55
- }
56
-
57
- /** Sanitizza il nome file: rimuove caratteri non sicuri, mantiene estensione */
58
- function sanitizeFilename(name: string): string {
59
- const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
60
- return base || 'file'
61
- }
62
-
63
- /** Genera chiave univoca per R2 (timestamp-sanitized-name) */
64
- function generateObjectKey(originalName: string): string {
65
- const timestamp = Math.floor(Date.now() / 1000)
66
- const sanitized = sanitizeFilename(originalName)
67
- return `${timestamp}-${sanitized}`
68
- }
69
-
70
- /** Restituisce l'URL base per costruire gli URL pubblici dei media */
71
- function getMediaBaseUrl(c: { req: { url: string }; env: UploadBindings }): string {
72
- const base = c.env.MEDIA_BASE_URL?.trim()
73
- if (base) return base.replace(/\/$/, '')
74
- return new URL(c.req.url).origin
75
- }
76
-
77
- /** Crea client S3 per R2 */
78
- export function createR2Client(env: UploadBindings): S3Client {
79
- if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.R2_ENDPOINT) {
80
- throw new Error('R2 credentials not configured')
81
- }
82
- return new S3Client({
83
- region: 'auto',
84
- endpoint: env.R2_ENDPOINT,
85
- credentials: {
86
- accessKeyId: env.R2_ACCESS_KEY_ID!,
87
- secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
88
- },
89
- forcePathStyle: true,
90
- })
91
- }
92
-
93
- /** Env minimale per delete R2 (solo R2_* e ENV) */
94
- export type R2DeleteEnv = Pick<
95
- UploadBindings,
96
- 'R2_ACCESS_KEY_ID' | 'R2_SECRET_ACCESS_KEY' | 'R2_ENDPOINT' | 'R2_BUCKET_NAME' | 'ENV' | 'DB'
97
- >
98
-
99
- /**
100
- * Elimina oggetti da R2 per le chiavi date.
101
- * Usato alla cancellazione entry per rimuovere i file associati da R2.
102
- */
103
- export async function deleteR2Objects(
104
- env: R2DeleteEnv,
105
- objectKeys: string[]
106
- ): Promise<void> {
107
- const isR2Configured =
108
- env.R2_ACCESS_KEY_ID &&
109
- env.R2_SECRET_ACCESS_KEY &&
110
- env.R2_ENDPOINT &&
111
- env.R2_BUCKET_NAME
112
-
113
- if (!isR2Configured || objectKeys.length === 0) {
114
- return
115
- }
116
-
117
- const s3Client = createR2Client(env as UploadBindings)
118
- for (const objectKey of objectKeys) {
119
- try {
120
- // Ottieni la dimensione prima della cancellazione per aggiornare il contatore
121
- let fileSize = 0
122
- try {
123
- const head = await s3Client.send(
124
- new HeadObjectCommand({
125
- Bucket: env.R2_BUCKET_NAME,
126
- Key: objectKey,
127
- })
128
- )
129
- fileSize = head.ContentLength ?? 0
130
- } catch (headErr) {
131
- // Se il file non esiste già su R2, fileSize resta 0
132
- if (env.ENV !== 'production') {
133
- console.warn('R2 head failed for key (skip size update)', objectKey, headErr)
134
- }
135
- }
136
-
137
- await s3Client.send(
138
- new DeleteObjectCommand({
139
- Bucket: env.R2_BUCKET_NAME,
140
- Key: objectKey,
141
- })
142
- )
143
-
144
- // Decrementa contatore storage in D1 se abbiamo trovato la dimensione
145
- if (fileSize > 0) {
146
- await env.DB.prepare(
147
- "UPDATE system_stats SET value = MAX(0, CAST(value AS INTEGER) - ?) WHERE id = 'total_storage_bytes'"
148
- ).bind(fileSize).run()
149
- }
150
-
151
- // Rimuovi dalla media library
152
- await env.DB.prepare('DELETE FROM media_objects WHERE key = ?').bind(objectKey).run()
153
- } catch (err) {
154
- if (env.ENV !== 'production') {
155
- console.warn('R2 delete failed for key', objectKey, err)
156
- }
157
- }
158
- }
159
- }
160
-
161
- export const uploadRoutes = new Hono<{
162
- Bindings: UploadBindings
163
- Variables: Variables
164
- }>()
165
-
166
- /** POST /upload - Carica file su R2, restituisce URL pubblico */
167
- uploadRoutes.post('/upload', async (c, next) => {
168
- await authMiddleware(c.env.JWT_SECRET)(c, next)
169
- }, async (c) => {
170
- try {
171
- const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
172
- if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
173
- return c.json({ error: 'R2 not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME' }, 500)
174
- }
175
-
176
- const contentType = c.req.header('Content-Type') ?? ''
177
- if (!contentType.includes('multipart/form-data')) {
178
- return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
179
- }
180
-
181
- const formData = await c.req.formData()
182
- const fileEntry = formData.get('file')
183
- // In Cloudflare/Workers il value può essere `string` o un oggetto (File/Blob-like).
184
- // Usiamo un guard sulle proprietà richieste.
185
- if (!isFileLike(fileEntry)) {
186
- return c.json({ error: 'No file provided. Use field name "file"' }, 400)
187
- }
188
- const file = fileEntry
189
-
190
- const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
191
- if (!mimeOk) {
192
- return c.json(
193
- { error: 'File type not allowed. Allowed: images and PDF' },
194
- 400
195
- )
196
- }
197
-
198
- if (file.size > MAX_FILE_SIZE_BYTES) {
199
- return c.json({ error: 'File too large. Max 5MB' }, 400)
200
- }
201
-
202
- const objectKey = generateObjectKey(file.name)
203
- const client = createR2Client(c.env)
204
-
205
- const body = await file.arrayBuffer()
206
- await client.send(
207
- new PutObjectCommand({
208
- Bucket: R2_BUCKET_NAME,
209
- Key: objectKey,
210
- Body: new Uint8Array(body),
211
- ContentType: file.type,
212
- })
213
- )
214
-
215
- // Aggiorna contatore storage in D1
216
- let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
217
- try {
218
- executionCtx = c.executionCtx
219
- } catch {
220
- // In ambiente di test Hono lancia se non presente
221
- }
222
-
223
- const uploadedBy = c.var.jwtPayload?.sub ?? ''
224
- if (executionCtx) {
225
- executionCtx.waitUntil((async () => {
226
- try {
227
- await c.env.DB.prepare(
228
- "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
229
- ).bind(file.size).run()
230
- } catch (err) {
231
- console.error('Failed to update storage stats on upload:', err)
232
- }
233
- try {
234
- await c.env.DB.prepare(
235
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
236
- ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run()
237
- } catch (err) {
238
- console.error('Failed to track media_objects on upload:', err)
239
- }
240
- })())
241
- } else {
242
- // Fallback sync
243
- c.env.DB.prepare(
244
- "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
245
- ).bind(file.size).run().catch(err => console.error('Failed to update storage stats on upload (sync fallback):', err))
246
- c.env.DB.prepare(
247
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
248
- ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run().catch(err => console.error('Failed to track media_objects (sync fallback):', err))
249
- }
250
-
251
- const baseUrl = getMediaBaseUrl(c)
252
- const publicUrl = `${baseUrl}/api/media/${encodeURIComponent(objectKey)}`
253
-
254
- logActivity(c, {
255
- action: 'upload',
256
- entityType: 'media',
257
- entityId: objectKey,
258
- details: { name: file.name, size: file.size, type: file.type }
259
- })
260
-
261
- return c.json({ url: publicUrl }, 200)
262
- } catch (err) {
263
- if (c.env.ENV !== 'production') {
264
- console.error('Upload error:', err)
265
- }
266
- return c.json({ error: 'Upload failed' }, 500)
267
- }
268
- })
269
-
270
- /** DELETE /upload/:key - Elimina un file da R2 */
271
- uploadRoutes.delete('/:key', async (c, next) => {
272
- await authMiddleware(c.env.JWT_SECRET)(c, next)
273
- }, async (c) => {
274
- const key = c.req.param('key')
275
- if (!key) return c.json({ error: 'Missing key' }, 400)
276
-
277
- await deleteR2Objects(c.env, [decodeURIComponent(key)])
278
- return c.json({ success: true }, 200)
279
- })
280
-
281
- /**
282
- * Serve un file da R2. Route pubblica (senza auth) per permettere
283
- * il caricamento delle immagini nei tag <img>.
284
- */
285
- export async function serveMediaHandler(
286
- c: { env: UploadBindings; req: { param: (key: string) => string } }
287
- ): Promise<Response> {
288
- const key = c.req.param('key')
289
- if (!key) {
290
- return new Response(JSON.stringify({ error: 'Missing key' }), {
291
- status: 400,
292
- headers: { 'Content-Type': 'application/json' },
293
- })
294
- }
295
-
296
- const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
297
- if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
298
- return new Response(JSON.stringify({ error: 'R2 not configured' }), {
299
- status: 500,
300
- headers: { 'Content-Type': 'application/json' },
301
- })
302
- }
303
-
304
- try {
305
- const client = createR2Client(c.env)
306
- const response = await client.send(
307
- new GetObjectCommand({
308
- Bucket: R2_BUCKET_NAME,
309
- Key: decodeURIComponent(key),
310
- })
311
- )
312
-
313
- if (!response.Body) {
314
- return new Response(JSON.stringify({ error: 'Not found' }), {
315
- status: 404,
316
- headers: { 'Content-Type': 'application/json' },
317
- })
318
- }
319
-
320
- const headers = new Headers()
321
- const ct = response.ContentType ?? 'application/octet-stream'
322
- headers.set('Content-Type', ct)
323
- headers.set('Cache-Control', 'public, max-age=31536000, immutable')
324
-
325
- return new Response(response.Body as ReadableStream, {
326
- status: 200,
327
- headers,
328
- })
329
- } catch {
330
- return new Response(JSON.stringify({ error: 'Not found' }), {
331
- status: 404,
332
- headers: { 'Content-Type': 'application/json' },
333
- })
334
- }
335
- }
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
+ return new Response('Internal error', { status: 500 })
185
+ }
186
+ }