@beechcms/api 0.6.0-preview.1 → 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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-BrJFdsGZ.js +39 -0
- package/assets/dashboard/assets/index-CIaoMxuB.css +1 -0
- package/assets/dashboard/assets/index-wTKgygsg.js +636 -0
- package/assets/dashboard/assets/pie-chart-recharts-BEaE1FOE.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-DmWM6imF.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +2 -2
- package/src/auth/__fixtures__/in-memory-hash-provider.ts +17 -17
- package/src/auth/__fixtures__/static-token-service.ts +22 -22
- package/src/auth/bcrypt-hash-provider.ts +24 -24
- package/src/auth/constants.ts +14 -14
- package/src/auth/generate-refresh-token.test.ts +23 -23
- package/src/auth/hash-provider.test.ts +55 -55
- package/src/auth/jose-token-service.ts +62 -62
- package/src/auth/jwt-claims-passthrough.test.ts +87 -87
- package/src/auth/login.test.ts +100 -100
- package/src/auth/login.ts +78 -78
- package/src/auth/refresh.ts +11 -11
- package/src/auth/token-service.test.ts +90 -90
- package/src/factory.ts +370 -368
- package/src/features/automations/__tests__/action-executors.test.ts +255 -255
- package/src/features/automations/__tests__/automation-runner.test.ts +196 -196
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +60 -60
- package/src/features/automations/__tests__/automations.handler.test.ts +264 -264
- package/src/features/automations/__tests__/automations.repository.test.ts +163 -163
- package/src/features/automations/__tests__/automations.schema.test.ts +224 -224
- package/src/features/automations/__tests__/context-resolver.test.ts +126 -126
- package/src/features/automations/__tests__/cron-runner.test.ts +267 -267
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +144 -144
- package/src/features/automations/__tests__/set-variable.executor.test.ts +383 -383
- package/src/features/automations/__tests__/template-grammar.test.ts +308 -308
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -142
- package/src/features/automations/__tests__/when-evaluator.test.ts +274 -274
- package/src/features/automations/__tests__/when-pushdown.test.ts +281 -281
- package/src/features/automations/action-executors/create-entry.executor.ts +27 -27
- package/src/features/automations/action-executors/edit-field.executor.ts +26 -26
- package/src/features/automations/action-executors/index.ts +37 -37
- package/src/features/automations/action-executors/send-mail.executor.ts +53 -53
- package/src/features/automations/action-executors/set-variable.executor.ts +159 -159
- package/src/features/automations/action-executors/webhook.executor.ts +67 -67
- package/src/features/automations/automation-runner.ts +85 -85
- package/src/features/automations/automation-runner.utils.ts +47 -47
- package/src/features/automations/automations.handler.ts +197 -197
- package/src/features/automations/automations.schema.ts +177 -177
- package/src/features/automations/context-resolver.ts +152 -152
- package/src/features/automations/cron-runner.ts +140 -140
- package/src/features/automations/cron-runner.utils.ts +44 -44
- package/src/features/automations/filter-translation.ts +46 -46
- package/src/features/automations/index.ts +16 -16
- package/src/features/automations/template-grammar.ts +245 -245
- package/src/features/automations/var-access-resolver.ts +140 -140
- package/src/features/automations/when-evaluator.ts +87 -87
- package/src/features/automations/when-pushdown.ts +57 -57
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -359
- package/src/features/backrefs/backrefs.handler.ts +168 -168
- package/src/features/backrefs/d1-backref.repository.ts +84 -84
- package/src/features/backrefs/index.ts +5 -5
- package/src/features/content/constants.ts +16 -16
- package/src/features/content/handlers/bulk.handler.ts +185 -185
- package/src/features/content/handlers/create.ts +129 -129
- package/src/features/content/handlers/delete.ts +62 -62
- package/src/features/content/handlers/facets.ts +49 -49
- package/src/features/content/handlers/get.ts +120 -120
- package/src/features/content/handlers/helpers.test.ts +180 -180
- package/src/features/content/handlers/helpers.ts +93 -93
- package/src/features/content/handlers/list.ts +174 -174
- package/src/features/content/handlers/update.ts +174 -174
- package/src/features/content/index.ts +26 -26
- package/src/features/dashboard-layout/__tests__/dashboard-layout.handler.test.ts +388 -0
- package/src/features/dashboard-layout/dashboard-layout.handler.ts +206 -0
- package/src/features/dashboard-layout/index.ts +5 -0
- package/src/features/draft/draft.handler.ts +195 -195
- package/src/features/draft/draft.middleware.ts +66 -66
- package/src/features/draft/index.ts +5 -5
- package/src/features/email/email.provider.ts +42 -42
- package/src/features/email/email.service.ts +98 -98
- package/src/features/email/email.types.ts +119 -119
- package/src/features/email/index.ts +33 -33
- package/src/features/email/providers/resend.ts +67 -67
- package/src/features/email/providers/smtp.ts +55 -55
- package/src/features/email/templates/automation-mail.ts +19 -19
- package/src/features/email/templates/password-changed.ts +63 -63
- package/src/features/email/templates/password-reset.ts +68 -68
- package/src/features/email/templates/shell.ts +96 -96
- package/src/features/notifications/index.ts +5 -5
- package/src/features/notifications/notifications.handler.ts +105 -105
- package/src/features/password-reset/index.ts +19 -19
- package/src/features/password-reset/request.ts +92 -92
- package/src/features/password-reset/reset.ts +102 -102
- package/src/features/rotate-field/index.ts +5 -5
- package/src/features/rotate-field/rotate-field.handler.ts +132 -132
- package/src/features/rotate-field/rotate-field.schema.ts +17 -17
- package/src/features/schema/schema.handler.ts +131 -131
- package/src/features/seeds/index.ts +5 -5
- package/src/features/seeds/seeds.handler.test.ts +1199 -632
- package/src/features/seeds/seeds.handler.ts +558 -693
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -555
- package/src/features/settings/settings.handler.ts +392 -392
- package/src/features/setup/index.ts +288 -204
- package/src/features/stats/index.ts +5 -5
- package/src/features/stats/stats.handler.ts +420 -420
- package/src/features/webhooks/index.ts +63 -63
- package/src/index.ts +65 -65
- package/src/media-utils.ts +82 -82
- package/src/middleware/auth-providers.middleware.test.ts +85 -0
- package/src/middleware/auth-providers.middleware.ts +45 -45
- package/src/middleware/observability.middleware.test.ts +93 -0
- package/src/middleware/observability.middleware.ts +70 -70
- package/src/middleware/rate-limit.middleware.ts +45 -45
- package/src/middleware/repository.middleware.ts +117 -114
- package/src/middleware/seed-registry.middleware.test.ts +97 -97
- package/src/middleware/seed-registry.middleware.ts +21 -21
- package/src/middleware/storage.middleware.ts +25 -25
- package/src/middleware.ts +45 -45
- package/src/public/access-policy.ts +27 -27
- package/src/public/api-key-middleware.ts +57 -57
- package/src/public/cache-utils.ts +38 -38
- package/src/public/entry-projection.ts +46 -46
- package/src/public/idempotency.ts +23 -23
- package/src/public/index.ts +16 -16
- package/src/public/problem-details.ts +124 -124
- package/src/public/public-add.ts +114 -114
- package/src/public/public-edit.ts +163 -163
- package/src/public/public-errors.ts +19 -19
- package/src/public/public-read.ts +63 -63
- package/src/public/public-routes.ts +88 -88
- package/src/public/query-builder.test.ts +224 -224
- package/src/public/query-builder.ts +156 -156
- package/src/public/rate-limit-middleware.ts +34 -34
- package/src/public/read-list.ts +54 -54
- package/src/public/read-single.ts +48 -48
- package/src/public/response-builder.ts +30 -30
- package/src/public/sanitize.ts +69 -69
- package/src/public/slug-utils.ts +18 -18
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +30 -30
- package/src/rate-limit/cloudflare-rate-limiter.ts +15 -15
- package/src/rate-limit/in-memory-rate-limiter.test.ts +37 -37
- package/src/rate-limit/in-memory-rate-limiter.ts +17 -17
- package/src/rate-limit/no-op-rate-limiter.test.ts +22 -22
- package/src/rate-limit/no-op-rate-limiter.ts +11 -11
- package/src/search-utils.test.ts +211 -211
- package/src/search-utils.ts +213 -213
- package/src/search.ts +65 -65
- package/src/shared/apply-policies.test.ts +81 -81
- package/src/shared/apply-policies.ts +67 -67
- package/src/shared/automations.repository.d1.ts +150 -150
- package/src/shared/background-notification-service.test.ts +62 -62
- package/src/shared/background-notification-service.ts +52 -52
- package/src/shared/base.repository.d1.ts +32 -32
- package/src/shared/content-utils.test.ts +165 -165
- package/src/shared/content-utils.ts +86 -86
- package/src/shared/content.repository.d1.test.ts +507 -458
- package/src/shared/content.repository.d1.ts +851 -851
- package/src/shared/d1-activity-log.repository.test.ts +140 -140
- package/src/shared/d1-activity-log.repository.ts +105 -105
- package/src/shared/d1-activity-logger.test.ts +86 -86
- package/src/shared/d1-activity-logger.ts +67 -67
- package/src/shared/d1-analytics.repository.test.ts +78 -78
- package/src/shared/d1-analytics.repository.ts +85 -85
- package/src/shared/d1-content-scan.repository.test.ts +80 -80
- package/src/shared/d1-content-scan.repository.ts +37 -37
- package/src/shared/d1-notification.repository.test.ts +128 -128
- package/src/shared/d1-notification.repository.ts +118 -118
- package/src/shared/d1-password-reset-token.repository.test.ts +81 -81
- package/src/shared/d1-password-reset-token.repository.ts +56 -56
- package/src/shared/d1-search.repository.test.ts +87 -87
- package/src/shared/d1-search.repository.ts +88 -88
- package/src/shared/d1-session.repository.test.ts +125 -125
- package/src/shared/d1-session.repository.ts +102 -102
- package/src/shared/d1-setup-checklist.repository.ts +36 -36
- package/src/shared/d1-user.repository.test.ts +151 -151
- package/src/shared/d1-user.repository.ts +119 -119
- package/src/shared/d1-widget.repository.test.ts +267 -221
- package/src/shared/d1-widget.repository.ts +385 -341
- package/src/shared/dashboard-layout.repository.d1.test.ts +135 -0
- package/src/shared/dashboard-layout.repository.d1.ts +58 -0
- package/src/shared/demo-data-sql.ts +216 -54
- package/src/shared/demo-data.repository.d1.ts +20 -20
- package/src/shared/execution-context-scheduler.ts +13 -13
- package/src/shared/fixed-clock.ts +25 -25
- package/src/shared/fts-sync.ts +8 -8
- package/src/shared/idempotency.repository.d1.test.ts +83 -83
- package/src/shared/idempotency.repository.d1.ts +60 -60
- package/src/shared/in-memory-activity-logger.ts +19 -19
- package/src/shared/in-memory-notification-service.ts +19 -19
- package/src/shared/in-memory-seed.repository.ts +58 -51
- package/src/shared/media.repository.d1.test.ts +107 -107
- package/src/shared/media.repository.d1.ts +68 -68
- package/src/shared/qstash-notification-service.test.ts +67 -67
- package/src/shared/qstash-notification-service.ts +55 -55
- package/src/shared/query-utils.ts +141 -141
- package/src/shared/request-utils.ts +26 -26
- package/src/shared/schema-mutator.d1.test.ts +142 -0
- package/src/shared/schema-mutator.d1.ts +64 -64
- package/src/shared/seed-layout.repository.d1.test.ts +114 -114
- package/src/shared/seed-layout.repository.d1.ts +62 -62
- package/src/shared/seed-registry-cache.test.ts +68 -68
- package/src/shared/seed-registry-cache.ts +49 -49
- package/src/shared/seed.repository.d1.test.ts +143 -143
- package/src/shared/seed.repository.d1.ts +98 -98
- package/src/shared/sequential-id-generator.ts +33 -33
- package/src/shared/site-settings.repository.d1.ts +53 -53
- package/src/shared/storage/factory.ts +41 -41
- package/src/shared/storage/s3-bucket.ts +195 -195
- package/src/shared/storage-utils.ts +40 -40
- package/src/shared/system-stats.repository.d1.test.ts +58 -58
- package/src/shared/system-stats.repository.d1.ts +48 -48
- package/src/types.ts +90 -87
- package/src/upload.ts +198 -198
- package/src/widget.test.ts +85 -0
- package/src/widget.ts +251 -212
- package/assets/dashboard/assets/index-BSjk0Sx9.js +0 -634
- package/assets/dashboard/assets/index-kRCYVN2B.css +0 -1
package/src/upload.ts
CHANGED
|
@@ -1,198 +1,198 @@
|
|
|
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
|
-
}
|
|
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
|
+
})
|