@beechcms/api 0.5.0 → 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.
- package/assets/dashboard/Searching.svg +1 -0
- package/assets/dashboard/assets/index-BSjk0Sx9.js +634 -0
- package/assets/dashboard/assets/index-kRCYVN2B.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/assets/dashboard/noResult.svg +43 -0
- package/assets/dashboard/working.svg +1 -0
- package/migrations/0000_v040_base.sql +27 -0
- package/package.json +13 -9
- package/src/auth/{in-memory-hash-provider.ts → __fixtures__/in-memory-hash-provider.ts} +4 -0
- package/src/auth/{static-token-service.ts → __fixtures__/static-token-service.ts} +4 -0
- package/src/auth/bcrypt-hash-provider.ts +6 -2
- package/src/auth/constants.ts +4 -0
- package/src/auth/generate-refresh-token.test.ts +8 -4
- package/src/auth/hash-provider.test.ts +14 -5
- package/src/auth/jose-token-service.ts +7 -0
- package/src/auth/jwt-claims-passthrough.test.ts +87 -0
- package/src/auth/login.test.ts +9 -1
- package/src/auth/login.ts +5 -1
- package/src/auth/refresh.ts +7 -1
- package/src/auth/token-service.test.ts +9 -1
- package/src/factory.ts +37 -16
- package/src/features/automations/__tests__/action-executors.test.ts +59 -72
- package/src/features/automations/__tests__/automation-runner.test.ts +4 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +7 -3
- package/src/features/automations/__tests__/automations.repository.test.ts +4 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +92 -2
- package/src/features/automations/__tests__/context-resolver.test.ts +4 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +11 -7
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +4 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +77 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +4 -0
- package/src/features/automations/__tests__/webhook.executor.test.ts +142 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +4 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +5 -1
- package/src/features/automations/action-executors/create-entry.executor.ts +4 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +4 -0
- package/src/features/automations/action-executors/index.ts +5 -1
- package/src/features/automations/action-executors/send-mail.executor.ts +13 -2
- package/src/features/automations/action-executors/set-variable.executor.ts +15 -2
- package/src/features/automations/action-executors/webhook.executor.ts +55 -13
- package/src/features/automations/automation-runner.ts +4 -0
- package/src/features/automations/automation-runner.utils.ts +4 -0
- package/src/features/automations/automations.handler.ts +4 -0
- package/src/features/automations/automations.schema.ts +19 -2
- package/src/features/automations/context-resolver.ts +4 -0
- package/src/features/automations/cron-runner.ts +4 -0
- package/src/features/automations/cron-runner.utils.ts +4 -0
- package/src/features/automations/filter-translation.ts +4 -0
- package/src/features/automations/index.ts +4 -0
- package/src/features/automations/template-grammar.ts +4 -0
- package/src/features/automations/var-access-resolver.ts +4 -0
- package/src/features/automations/when-evaluator.ts +4 -0
- package/src/features/automations/when-pushdown.ts +4 -0
- package/src/features/backrefs/__tests__/backrefs.handler.test.ts +359 -0
- package/src/features/backrefs/backrefs.handler.ts +168 -0
- package/src/features/backrefs/d1-backref.repository.ts +84 -0
- package/src/features/backrefs/index.ts +5 -0
- package/src/features/content/constants.ts +6 -0
- package/src/features/content/handlers/bulk.handler.ts +185 -0
- package/src/features/content/handlers/create.ts +19 -55
- package/src/features/content/handlers/delete.ts +12 -37
- package/src/features/content/handlers/facets.ts +4 -0
- package/src/features/content/handlers/get.ts +4 -0
- package/src/features/content/handlers/helpers.test.ts +180 -0
- package/src/features/content/handlers/helpers.ts +93 -0
- package/src/features/content/handlers/list.ts +105 -19
- package/src/features/content/handlers/update.ts +19 -64
- package/src/features/content/index.ts +6 -0
- package/src/features/draft/draft.handler.ts +33 -8
- package/src/features/draft/draft.middleware.ts +4 -0
- package/src/features/draft/index.ts +4 -0
- package/src/features/email/email.provider.ts +4 -0
- package/src/features/email/email.service.ts +50 -45
- package/src/features/email/email.types.ts +12 -1
- package/src/features/email/index.ts +4 -0
- package/src/features/email/providers/resend.ts +4 -0
- package/src/features/email/providers/smtp.ts +55 -0
- package/src/features/email/templates/automation-mail.ts +4 -0
- package/src/features/email/templates/password-changed.ts +4 -0
- package/src/features/email/templates/password-reset.ts +4 -0
- package/src/features/email/templates/shell.ts +4 -0
- package/src/features/notifications/index.ts +4 -0
- package/src/features/notifications/notifications.handler.ts +4 -0
- package/src/features/password-reset/index.ts +5 -1
- package/src/features/password-reset/request.ts +12 -2
- package/src/features/password-reset/reset.ts +12 -2
- package/src/features/rotate-field/index.ts +4 -0
- package/src/features/rotate-field/rotate-field.handler.ts +4 -0
- package/src/features/rotate-field/rotate-field.schema.ts +4 -0
- package/src/features/schema/schema.handler.ts +118 -3
- package/src/features/seeds/index.ts +5 -0
- package/src/features/seeds/seeds.handler.test.ts +632 -0
- package/src/features/seeds/seeds.handler.ts +693 -0
- package/src/features/settings/__tests__/settings.handler.test.ts +555 -0
- package/src/features/settings/settings.handler.ts +98 -8
- package/src/features/setup/index.ts +124 -11
- package/src/features/stats/index.ts +4 -0
- package/src/features/stats/stats.handler.ts +11 -21
- package/src/features/webhooks/index.ts +63 -0
- package/src/index.ts +28 -19
- package/src/media-utils.ts +4 -0
- package/src/middleware/auth-providers.middleware.ts +13 -0
- package/src/middleware/observability.middleware.ts +21 -3
- package/src/middleware/rate-limit.middleware.ts +4 -0
- package/src/middleware/repository.middleware.ts +25 -2
- package/src/middleware/seed-registry.middleware.test.ts +97 -0
- package/src/middleware/seed-registry.middleware.ts +21 -0
- package/src/middleware/storage.middleware.ts +4 -0
- package/src/middleware.ts +5 -7
- package/src/public/access-policy.ts +4 -0
- package/src/public/api-key-middleware.ts +4 -0
- package/src/public/cache-utils.ts +4 -0
- package/src/public/entry-projection.ts +4 -0
- package/src/public/idempotency.ts +4 -0
- package/src/public/index.ts +4 -0
- package/src/public/problem-details.ts +71 -0
- package/src/public/public-add.ts +4 -0
- package/src/public/public-edit.ts +4 -0
- package/src/public/public-errors.ts +4 -0
- package/src/public/public-read.ts +4 -0
- package/src/public/public-routes.ts +4 -0
- package/src/public/query-builder.test.ts +4 -0
- package/src/public/query-builder.ts +4 -0
- package/src/public/rate-limit-middleware.ts +4 -0
- package/src/public/read-list.ts +4 -0
- package/src/public/read-single.ts +4 -0
- package/src/public/response-builder.ts +4 -0
- package/src/public/sanitize.ts +4 -0
- package/src/public/slug-utils.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +4 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +4 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +4 -0
- package/src/rate-limit/no-op-rate-limiter.ts +4 -0
- package/src/search-utils.test.ts +4 -0
- package/src/search-utils.ts +4 -0
- package/src/search.ts +4 -0
- package/src/shared/apply-policies.test.ts +4 -0
- package/src/shared/apply-policies.ts +4 -0
- package/src/shared/automations.repository.d1.ts +4 -0
- package/src/shared/background-notification-service.test.ts +4 -0
- package/src/shared/background-notification-service.ts +4 -0
- package/src/shared/base.repository.d1.ts +4 -0
- package/src/shared/content-utils.test.ts +4 -0
- package/src/shared/content-utils.ts +4 -0
- package/src/shared/content.repository.d1.test.ts +147 -1
- package/src/shared/content.repository.d1.ts +535 -66
- package/src/shared/d1-activity-log.repository.test.ts +4 -0
- package/src/shared/d1-activity-log.repository.ts +4 -0
- package/src/shared/d1-activity-logger.test.ts +4 -0
- package/src/shared/d1-activity-logger.ts +4 -0
- package/src/shared/d1-analytics.repository.test.ts +4 -0
- package/src/shared/d1-analytics.repository.ts +4 -0
- package/src/shared/d1-content-scan.repository.test.ts +9 -5
- package/src/shared/d1-content-scan.repository.ts +15 -7
- package/src/shared/d1-notification.repository.test.ts +4 -0
- package/src/shared/d1-notification.repository.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +4 -0
- package/src/shared/d1-password-reset-token.repository.ts +4 -0
- package/src/shared/d1-search.repository.test.ts +4 -0
- package/src/shared/d1-search.repository.ts +4 -0
- package/src/shared/d1-session.repository.test.ts +4 -0
- package/src/shared/d1-session.repository.ts +4 -0
- package/src/shared/d1-setup-checklist.repository.ts +36 -0
- package/src/shared/d1-user.repository.test.ts +8 -4
- package/src/shared/d1-user.repository.ts +15 -5
- package/src/shared/d1-widget.repository.test.ts +4 -0
- package/src/shared/d1-widget.repository.ts +4 -0
- package/src/shared/demo-data-sql.ts +54 -0
- package/src/shared/demo-data.repository.d1.ts +20 -0
- package/src/shared/execution-context-scheduler.ts +4 -0
- package/src/shared/fixed-clock.ts +4 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/idempotency.repository.d1.test.ts +4 -0
- package/src/shared/idempotency.repository.d1.ts +4 -0
- package/src/shared/in-memory-activity-logger.ts +4 -0
- package/src/shared/in-memory-notification-service.ts +4 -0
- package/src/shared/in-memory-seed.repository.ts +51 -0
- package/src/shared/media.repository.d1.test.ts +4 -0
- package/src/shared/media.repository.d1.ts +4 -0
- package/src/shared/qstash-notification-service.test.ts +67 -0
- package/src/shared/qstash-notification-service.ts +55 -0
- package/src/shared/query-utils.ts +4 -0
- package/src/shared/request-utils.ts +4 -0
- package/src/shared/schema-mutator.d1.ts +64 -0
- package/src/shared/seed-layout.repository.d1.test.ts +114 -0
- package/src/shared/seed-layout.repository.d1.ts +62 -0
- package/src/shared/seed-registry-cache.test.ts +68 -0
- package/src/shared/seed-registry-cache.ts +49 -0
- package/src/shared/seed.repository.d1.test.ts +143 -0
- package/src/shared/seed.repository.d1.ts +98 -0
- package/src/shared/sequential-id-generator.ts +11 -0
- package/src/shared/site-settings.repository.d1.ts +53 -0
- package/src/shared/storage/factory.ts +16 -15
- package/src/shared/storage/s3-bucket.ts +34 -2
- package/src/shared/storage-utils.ts +4 -0
- package/src/shared/system-stats.repository.d1.test.ts +4 -0
- package/src/shared/system-stats.repository.d1.ts +5 -1
- package/src/types.ts +23 -3
- package/src/upload.ts +134 -123
- package/src/widget.ts +6 -2
- package/assets/dashboard/assets/index-B1mUfgiK.css +0 -1
- package/assets/dashboard/assets/index-CHxxc_id.js +0 -629
- 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
|
|
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
|
-
|
|
11
|
-
const
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
76
|
-
uploadRoutes.post('/upload', async (c) => {
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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)
|
|
132
136
|
|
|
133
|
-
|
|
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
|
-
|
|
151
|
-
}
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|