@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/{index-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
  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/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -267
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
package/src/upload.ts CHANGED
@@ -1,331 +1,179 @@
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) => {
168
- try {
169
- const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
170
- if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
171
- return c.json({ error: 'R2 not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME' }, 500)
172
- }
173
-
174
- const contentType = c.req.header('Content-Type') ?? ''
175
- if (!contentType.includes('multipart/form-data')) {
176
- return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
177
- }
178
-
179
- const formData = await c.req.formData()
180
- const fileEntry = formData.get('file')
181
- // In Cloudflare/Workers il value può essere `string` o un oggetto (File/Blob-like).
182
- // Usiamo un guard sulle proprietà richieste.
183
- if (!isFileLike(fileEntry)) {
184
- return c.json({ error: 'No file provided. Use field name "file"' }, 400)
185
- }
186
- const file = fileEntry
187
-
188
- const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
189
- if (!mimeOk) {
190
- return c.json(
191
- { error: 'File type not allowed. Allowed: images and PDF' },
192
- 400
193
- )
194
- }
195
-
196
- if (file.size > MAX_FILE_SIZE_BYTES) {
197
- return c.json({ error: 'File too large. Max 5MB' }, 400)
198
- }
199
-
200
- const objectKey = generateObjectKey(file.name)
201
- const client = createR2Client(c.env)
202
-
203
- const body = await file.arrayBuffer()
204
- await client.send(
205
- new PutObjectCommand({
206
- Bucket: R2_BUCKET_NAME,
207
- Key: objectKey,
208
- Body: new Uint8Array(body),
209
- ContentType: file.type,
210
- })
211
- )
212
-
213
- // Aggiorna contatore storage in D1
214
- let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
215
- try {
216
- executionCtx = c.executionCtx
217
- } catch {
218
- // In ambiente di test Hono lancia se non presente
219
- }
220
-
221
- const uploadedBy = c.var.jwtPayload?.sub ?? ''
222
- if (executionCtx) {
223
- executionCtx.waitUntil((async () => {
224
- try {
225
- await c.env.DB.prepare(
226
- "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
227
- ).bind(file.size).run()
228
- } catch (err) {
229
- console.error('Failed to update storage stats on upload:', err)
230
- }
231
- try {
232
- await c.env.DB.prepare(
233
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
234
- ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run()
235
- } catch (err) {
236
- console.error('Failed to track media_objects on upload:', err)
237
- }
238
- })())
239
- } else {
240
- // Fallback sync
241
- c.env.DB.prepare(
242
- "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
243
- ).bind(file.size).run().catch(err => console.error('Failed to update storage stats on upload (sync fallback):', err))
244
- c.env.DB.prepare(
245
- 'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
246
- ).bind(objectKey, file.name, file.type, file.size, uploadedBy).run().catch(err => console.error('Failed to track media_objects (sync fallback):', err))
247
- }
248
-
249
- const baseUrl = getMediaBaseUrl(c)
250
- const publicUrl = `${baseUrl}/api/media/${encodeURIComponent(objectKey)}`
251
-
252
- logActivity(c, {
253
- action: 'upload',
254
- entityType: 'media',
255
- entityId: objectKey,
256
- details: { name: file.name, size: file.size, type: file.type }
257
- })
258
-
259
- return c.json({ url: publicUrl }, 200)
260
- } catch (err) {
261
- if (c.env.ENV !== 'production') {
262
- console.error('Upload error:', err)
263
- }
264
- return c.json({ error: 'Upload failed' }, 500)
265
- }
266
- })
267
-
268
- /** DELETE /api/upload/:key - Elimina un file da R2 */
269
- uploadRoutes.delete('/upload/:key', async (c) => {
270
- const key = c.req.param('key')
271
- if (!key) return c.json({ error: 'Missing key' }, 400)
272
-
273
- await deleteR2Objects(c.env, [decodeURIComponent(key)])
274
- return c.json({ success: true }, 200)
275
- })
276
-
277
- /**
278
- * Serve un file da R2. Route pubblica (senza auth) per permettere
279
- * il caricamento delle immagini nei tag <img>.
280
- */
281
- export async function serveMediaHandler(
282
- c: { env: UploadBindings; req: { param: (key: string) => string } }
283
- ): Promise<Response> {
284
- const key = c.req.param('key')
285
- if (!key) {
286
- return new Response(JSON.stringify({ error: 'Missing key' }), {
287
- status: 400,
288
- headers: { 'Content-Type': 'application/json' },
289
- })
290
- }
291
-
292
- const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
293
- if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
294
- return new Response(JSON.stringify({ error: 'R2 not configured' }), {
295
- status: 500,
296
- headers: { 'Content-Type': 'application/json' },
297
- })
298
- }
299
-
300
- try {
301
- const client = createR2Client(c.env)
302
- const response = await client.send(
303
- new GetObjectCommand({
304
- Bucket: R2_BUCKET_NAME,
305
- Key: decodeURIComponent(key),
306
- })
307
- )
308
-
309
- if (!response.Body) {
310
- return new Response(JSON.stringify({ error: 'Not found' }), {
311
- status: 404,
312
- headers: { 'Content-Type': 'application/json' },
313
- })
314
- }
315
-
316
- const headers = new Headers()
317
- const ct = response.ContentType ?? 'application/octet-stream'
318
- headers.set('Content-Type', ct)
319
- headers.set('Cache-Control', 'public, max-age=31536000, immutable')
320
-
321
- return new Response(response.Body as ReadableStream, {
322
- status: 200,
323
- headers,
324
- })
325
- } catch {
326
- return new Response(JSON.stringify({ error: 'Not found' }), {
327
- status: 404,
328
- headers: { 'Content-Type': 'application/json' },
329
- })
330
- }
331
- }
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 { logActivity } from './shared/activity-logger'
9
+ import { AppEnv } from './types'
10
+
11
+ /** Prefissi MIME consentiti (immagini e PDF) */
12
+ const ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf']
13
+
14
+ /** Dimensione massima file: 5 MB */
15
+ const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
16
+
17
+ type FileLike = {
18
+ name: string
19
+ type: string
20
+ size: number
21
+ arrayBuffer: () => Promise<ArrayBuffer>
22
+ }
23
+
24
+ function isFileLike(value: unknown): value is FileLike {
25
+ if (!value || typeof value === 'string') return false
26
+ const v = value as Record<string, unknown>
27
+ return (
28
+ typeof v.name === 'string' &&
29
+ typeof v.type === 'string' &&
30
+ typeof v.size === 'number' &&
31
+ typeof v.arrayBuffer === 'function'
32
+ )
33
+ }
34
+
35
+ /** Sanitizza il nome file */
36
+ function sanitizeFilename(name: string): string {
37
+ const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
38
+ return base || 'file'
39
+ }
40
+
41
+ /** Genera chiave univoca (timestamp-sanitized-name) */
42
+ function generateObjectKey(originalName: string): string {
43
+ const timestamp = Math.floor(Date.now() / 1000)
44
+ const sanitized = sanitizeFilename(originalName)
45
+ return `${timestamp}-${sanitized}`
46
+ }
47
+
48
+ /**
49
+ * Elimina oggetti dallo storage e tracciamento dal DB.
50
+ */
51
+ export async function deleteR2Objects(
52
+ c: { var: { bucket: any, mediaRepository: any, systemStatsRepository: any } },
53
+ objectKeys: string[]
54
+ ): Promise<void> {
55
+ const { bucket, mediaRepository, systemStatsRepository } = c.var
56
+
57
+ for (const key of objectKeys) {
58
+ try {
59
+ const media = await mediaRepository.getByKey(key)
60
+ const size = media?.size_bytes ?? 0
61
+
62
+ await bucket.delete(key)
63
+ await mediaRepository.untrack(key)
64
+
65
+ if (size > 0) {
66
+ await systemStatsRepository.decrementStorage(size)
67
+ }
68
+ } catch (err) {
69
+ console.warn(`Failed to delete media object: ${key}`, err)
70
+ }
71
+ }
72
+ }
73
+
74
+ export const uploadRoutes = new Hono<AppEnv>()
75
+
76
+ /** POST /upload - Carica file su BeechBucket */
77
+ uploadRoutes.post('/upload', async (c) => {
78
+ try {
79
+ const bucket = c.var.bucket
80
+ const mediaRepo = c.var.mediaRepository
81
+ const statsRepo = c.var.systemStatsRepository
82
+
83
+ const contentType = c.req.header('Content-Type') ?? ''
84
+ if (!contentType.includes('multipart/form-data')) {
85
+ return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
86
+ }
87
+
88
+ const formData = await c.req.formData()
89
+ const fileEntry = formData.get('file')
90
+
91
+ if (!isFileLike(fileEntry)) {
92
+ return c.json({ error: 'No file provided. Use field name "file"' }, 400)
93
+ }
94
+ const file = fileEntry
95
+
96
+ const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
97
+ if (!mimeOk) {
98
+ return c.json({ error: 'File type not allowed. Allowed: images and PDF' }, 400)
99
+ }
100
+
101
+ if (file.size > MAX_FILE_SIZE_BYTES) {
102
+ return c.json({ error: 'File too large. Max 5MB' }, 400)
103
+ }
104
+
105
+ const objectKey = generateObjectKey(file.name)
106
+ const body = await file.arrayBuffer()
107
+
108
+ // 1. Upload allo storage
109
+ await bucket.put(objectKey, body, { contentType: file.type })
110
+
111
+ // 2. Aggiorna DB e Stats (in background se possibile)
112
+ const uploadedBy = c.var.jwtPayload?.sub ?? ''
113
+ const trackOperation = (async () => {
114
+ try {
115
+ await statsRepo.incrementStorage(file.size)
116
+ await mediaRepo.trackUpload({
117
+ key: objectKey,
118
+ filename: file.name,
119
+ mime_type: file.type,
120
+ size_bytes: file.size,
121
+ uploaded_by: uploadedBy
122
+ })
123
+ } catch (err) {
124
+ console.error('Failed to update DB tracking for upload:', err)
125
+ }
126
+ })()
127
+
128
+ try {
129
+ c.executionCtx.waitUntil(trackOperation)
130
+ } catch {
131
+ await trackOperation
132
+ }
133
+
134
+ const publicUrl = bucket.getUrl(objectKey)
135
+
136
+ logActivity(c, {
137
+ action: 'upload',
138
+ entityType: 'media',
139
+ entityId: objectKey,
140
+ details: { name: file.name, size: file.size, type: file.type }
141
+ })
142
+
143
+ return c.json({ url: publicUrl }, 200)
144
+ } catch (err) {
145
+ console.error('Upload error:', err)
146
+ return c.json({ error: 'Upload failed' }, 500)
147
+ }
148
+ })
149
+
150
+ /** DELETE /upload/:key - Elimina un file */
151
+ uploadRoutes.delete('/upload/:key', async (c) => {
152
+ const key = c.req.param('key')
153
+ if (!key) return c.json({ error: 'Missing key' }, 400)
154
+
155
+ await deleteR2Objects(c, [decodeURIComponent(key)])
156
+ return c.json({ success: true }, 200)
157
+ })
158
+
159
+ /**
160
+ * Serve un file dallo storage.
161
+ */
162
+ export async function serveMediaHandler(c: any): Promise<Response> {
163
+ const key = c.req.param('key')
164
+ if (!key) return new Response('Missing key', { status: 400 })
165
+
166
+ const bucket = c.var.bucket
167
+ try {
168
+ const object = await bucket.get(decodeURIComponent(key))
169
+ if (!object) return new Response('Not found', { status: 404 })
170
+
171
+ const headers = new Headers()
172
+ headers.set('Content-Type', object.contentType ?? 'application/octet-stream')
173
+ headers.set('Cache-Control', 'public, max-age=31536000, immutable')
174
+
175
+ return new Response(object.body, { status: 200, headers })
176
+ } catch (err) {
177
+ return new Response('Internal error', { status: 500 })
178
+ }
179
+ }