@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.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +10 -10
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +74 -91
- package/src/auth/refresh.ts +5 -127
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +339 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +157 -0
- package/src/features/content/handlers/delete.ts +80 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +211 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +283 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +101 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +82 -88
- package/src/features/password-reset/reset.ts +92 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +128 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +301 -249
- package/src/features/setup/index.ts +91 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +430 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +60 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +47 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +156 -183
- package/src/public/public-edit.ts +159 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +30 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +209 -192
- package/src/search.ts +61 -72
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +63 -36
- package/src/upload.ts +186 -335
- package/src/widget.ts +208 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MediaRepository, MediaObject } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Implementation of MediaRepository using Cloudflare D1.
|
|
5
|
+
* Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
|
|
6
|
+
*/
|
|
7
|
+
export class D1MediaRepository implements MediaRepository {
|
|
8
|
+
constructor(private database: D1Database) {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers a new media upload in the database.
|
|
12
|
+
*/
|
|
13
|
+
async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
|
|
14
|
+
await this.database.prepare(
|
|
15
|
+
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
16
|
+
).bind(
|
|
17
|
+
mediaObject.key,
|
|
18
|
+
mediaObject.filename,
|
|
19
|
+
mediaObject.mime_type,
|
|
20
|
+
mediaObject.size_bytes,
|
|
21
|
+
mediaObject.uploaded_by
|
|
22
|
+
).run()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Retrieves a media object's metadata by its unique key.
|
|
27
|
+
*/
|
|
28
|
+
async getByKey(mediaKey: string): Promise<MediaObject | null> {
|
|
29
|
+
return await this.database.prepare(
|
|
30
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
|
|
31
|
+
).bind(mediaKey).first<MediaObject>()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Removes the metadata tracking for a specific media key.
|
|
36
|
+
*/
|
|
37
|
+
async untrack(mediaKey: string): Promise<void> {
|
|
38
|
+
await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lists media objects with pagination support.
|
|
43
|
+
*/
|
|
44
|
+
async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
|
|
45
|
+
const { results: mediaItems } = await this.database.prepare(
|
|
46
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
|
47
|
+
).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
|
|
48
|
+
|
|
49
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
items: mediaItems ?? [],
|
|
53
|
+
total: totalCountRecord?.total ?? 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the total number of media objects in the database.
|
|
59
|
+
*/
|
|
60
|
+
async count(): Promise<number> {
|
|
61
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
62
|
+
return totalCountRecord?.total ?? 0
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
import type { FilterGroup, FilterType } from '@beechcms/core'
|
|
2
|
-
|
|
3
|
-
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
4
|
-
export interface ContentEntry {
|
|
5
|
-
id: string
|
|
6
|
-
schema_slug: string
|
|
7
|
-
slug: string | null
|
|
8
|
-
status: string
|
|
9
|
-
data: Record<string, unknown>
|
|
10
|
-
hasPendingDraft: boolean
|
|
11
|
-
created_at: number | null
|
|
12
|
-
updated_at: number | null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type QueryFilterType = FilterType
|
|
16
|
-
|
|
17
|
-
export type QueryFilterOperator =
|
|
18
|
-
| 'eq'
|
|
19
|
-
| 'gt'
|
|
20
|
-
| 'gte'
|
|
21
|
-
| 'lt'
|
|
22
|
-
| 'lte'
|
|
23
|
-
| 'contains'
|
|
24
|
-
| 'is_empty'
|
|
25
|
-
| 'is_not_empty'
|
|
26
|
-
|
|
27
|
-
export interface QueryFilterCondition {
|
|
28
|
-
id?: string
|
|
29
|
-
op: QueryFilterOperator
|
|
30
|
-
value: string | number | boolean | null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Formato inviato dalla dashboard (usa columnId). */
|
|
34
|
-
export interface QueryFilterGroup {
|
|
35
|
-
columnId: string
|
|
36
|
-
label?: string
|
|
37
|
-
type: QueryFilterType
|
|
38
|
-
conditions: QueryFilterCondition[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
42
|
-
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
43
|
-
])
|
|
44
|
-
|
|
45
|
-
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
46
|
-
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function cleanStr(val: unknown): string | null {
|
|
50
|
-
return (typeof val === 'string' && val.trim()) || null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
54
|
-
const cleaned = cleanStr(data)
|
|
55
|
-
if (!cleaned) return {}
|
|
56
|
-
try {
|
|
57
|
-
const parsed = JSON.parse(cleaned)
|
|
58
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
59
|
-
? (parsed as Record<string, unknown>)
|
|
60
|
-
: {}
|
|
61
|
-
} catch {
|
|
62
|
-
return {}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
67
|
-
if (!value) return fallback
|
|
68
|
-
const parsed = Number.parseInt(value, 10)
|
|
69
|
-
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
70
|
-
return parsed
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
74
|
-
if (!cond || typeof cond !== 'object') return null
|
|
75
|
-
const candidate = cond as Record<string, unknown>
|
|
76
|
-
if (!isQueryFilterOperator(candidate.op)) return null
|
|
77
|
-
const rawValue = candidate.value
|
|
78
|
-
let parsedValue: QueryFilterCondition['value'] = null
|
|
79
|
-
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
80
|
-
parsedValue = rawValue
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
84
|
-
op: candidate.op,
|
|
85
|
-
value: parsedValue,
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
90
|
-
if (!group || typeof group !== 'object') return null
|
|
91
|
-
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
92
|
-
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
const validConditions: QueryFilterCondition[] = []
|
|
96
|
-
for (const cond of rawConds) {
|
|
97
|
-
const parsedCond = parseCondition(cond)
|
|
98
|
-
if (parsedCond) validConditions.push(parsedCond)
|
|
99
|
-
}
|
|
100
|
-
if (validConditions.length === 0) return null
|
|
101
|
-
return {
|
|
102
|
-
columnId,
|
|
103
|
-
label: typeof label === 'string' ? label : undefined,
|
|
104
|
-
type,
|
|
105
|
-
conditions: validConditions,
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
110
|
-
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
111
|
-
if (!raw) return []
|
|
112
|
-
let parsed: unknown
|
|
113
|
-
try {
|
|
114
|
-
parsed = JSON.parse(raw)
|
|
115
|
-
} catch {
|
|
116
|
-
return []
|
|
117
|
-
}
|
|
118
|
-
if (!parsed || typeof parsed !== 'object') return []
|
|
119
|
-
const result: QueryFilterGroup[] = []
|
|
120
|
-
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
121
|
-
const parsedGroup = parseFilterGroup(group)
|
|
122
|
-
if (parsedGroup) result.push(parsedGroup)
|
|
123
|
-
}
|
|
124
|
-
return result
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
129
|
-
* compatibile con buildSelectQuery del Botanical Engine.
|
|
130
|
-
*/
|
|
131
|
-
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
132
|
-
return groups.map((g) => ({
|
|
133
|
-
column: g.columnId,
|
|
134
|
-
type: g.type,
|
|
135
|
-
conditions: g.conditions,
|
|
136
|
-
}))
|
|
137
|
-
}
|
|
1
|
+
import type { FilterGroup, FilterType } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
4
|
+
export interface ContentEntry {
|
|
5
|
+
id: string
|
|
6
|
+
schema_slug: string
|
|
7
|
+
slug: string | null
|
|
8
|
+
status: string
|
|
9
|
+
data: Record<string, unknown>
|
|
10
|
+
hasPendingDraft: boolean
|
|
11
|
+
created_at: number | null
|
|
12
|
+
updated_at: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type QueryFilterType = FilterType
|
|
16
|
+
|
|
17
|
+
export type QueryFilterOperator =
|
|
18
|
+
| 'eq'
|
|
19
|
+
| 'gt'
|
|
20
|
+
| 'gte'
|
|
21
|
+
| 'lt'
|
|
22
|
+
| 'lte'
|
|
23
|
+
| 'contains'
|
|
24
|
+
| 'is_empty'
|
|
25
|
+
| 'is_not_empty'
|
|
26
|
+
|
|
27
|
+
export interface QueryFilterCondition {
|
|
28
|
+
id?: string
|
|
29
|
+
op: QueryFilterOperator
|
|
30
|
+
value: string | number | boolean | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Formato inviato dalla dashboard (usa columnId). */
|
|
34
|
+
export interface QueryFilterGroup {
|
|
35
|
+
columnId: string
|
|
36
|
+
label?: string
|
|
37
|
+
type: QueryFilterType
|
|
38
|
+
conditions: QueryFilterCondition[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
42
|
+
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
46
|
+
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cleanStr(val: unknown): string | null {
|
|
50
|
+
return (typeof val === 'string' && val.trim()) || null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
54
|
+
const cleaned = cleanStr(data)
|
|
55
|
+
if (!cleaned) return {}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(cleaned)
|
|
58
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
59
|
+
? (parsed as Record<string, unknown>)
|
|
60
|
+
: {}
|
|
61
|
+
} catch {
|
|
62
|
+
return {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
67
|
+
if (!value) return fallback
|
|
68
|
+
const parsed = Number.parseInt(value, 10)
|
|
69
|
+
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
70
|
+
return parsed
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
74
|
+
if (!cond || typeof cond !== 'object') return null
|
|
75
|
+
const candidate = cond as Record<string, unknown>
|
|
76
|
+
if (!isQueryFilterOperator(candidate.op)) return null
|
|
77
|
+
const rawValue = candidate.value
|
|
78
|
+
let parsedValue: QueryFilterCondition['value'] = null
|
|
79
|
+
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
80
|
+
parsedValue = rawValue
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
84
|
+
op: candidate.op,
|
|
85
|
+
value: parsedValue,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
90
|
+
if (!group || typeof group !== 'object') return null
|
|
91
|
+
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
92
|
+
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
const validConditions: QueryFilterCondition[] = []
|
|
96
|
+
for (const cond of rawConds) {
|
|
97
|
+
const parsedCond = parseCondition(cond)
|
|
98
|
+
if (parsedCond) validConditions.push(parsedCond)
|
|
99
|
+
}
|
|
100
|
+
if (validConditions.length === 0) return null
|
|
101
|
+
return {
|
|
102
|
+
columnId,
|
|
103
|
+
label: typeof label === 'string' ? label : undefined,
|
|
104
|
+
type,
|
|
105
|
+
conditions: validConditions,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
110
|
+
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
111
|
+
if (!raw) return []
|
|
112
|
+
let parsed: unknown
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw)
|
|
115
|
+
} catch {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
if (!parsed || typeof parsed !== 'object') return []
|
|
119
|
+
const result: QueryFilterGroup[] = []
|
|
120
|
+
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
121
|
+
const parsedGroup = parseFilterGroup(group)
|
|
122
|
+
if (parsedGroup) result.push(parsedGroup)
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
129
|
+
* compatibile con buildSelectQuery del Botanical Engine.
|
|
130
|
+
*/
|
|
131
|
+
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
132
|
+
return groups.map((g) => ({
|
|
133
|
+
column: g.columnId,
|
|
134
|
+
type: g.type,
|
|
135
|
+
conditions: g.conditions,
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HonoRequest } from "hono"
|
|
2
|
+
|
|
3
|
+
/** The header Cloudflare sets on every incoming request to the Worker. */
|
|
4
|
+
const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
|
|
5
|
+
|
|
6
|
+
/** The fallback value used when the IP header is absent (local dev, unit tests). */
|
|
7
|
+
const UNKNOWN_IP = "unknown"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the real client IP address from a Cloudflare Worker request.
|
|
11
|
+
*
|
|
12
|
+
* Cloudflare injects the cf-connecting-ip header on every request that
|
|
13
|
+
* passes through the edge. In local development (wrangler dev) or in
|
|
14
|
+
* unit tests the header may be absent, in which case the string "unknown"
|
|
15
|
+
* is returned so that rate-limiter keys remain non-empty and safe to use.
|
|
16
|
+
*
|
|
17
|
+
* Never derive security decisions from this value alone; treat it as a
|
|
18
|
+
* best-effort hint for rate-limiting and logging purposes only.
|
|
19
|
+
*/
|
|
20
|
+
export function getClientIp(request: HonoRequest): string {
|
|
21
|
+
return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IIdGenerator } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
const ID_PADDING_WIDTH = 4
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Test-only IIdGenerator implementation. Each call returns a stable,
|
|
7
|
+
* monotonically increasing identifier (`test-id-0001`, `test-id-0002`, …)
|
|
8
|
+
* so snapshot assertions and insert-order checks remain deterministic
|
|
9
|
+
* across runs.
|
|
10
|
+
*/
|
|
11
|
+
export class SequentialIdGenerator implements IIdGenerator {
|
|
12
|
+
private counter = 0
|
|
13
|
+
|
|
14
|
+
uuid(): string {
|
|
15
|
+
this.counter += 1
|
|
16
|
+
return `test-id-${String(this.counter).padStart(ID_PADDING_WIDTH, '0')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
reset(): void {
|
|
20
|
+
this.counter = 0
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
|
|
2
|
+
import { Env } from '../../types'
|
|
3
|
+
import { R2BindingBucket } from './r2-binding-bucket'
|
|
4
|
+
import { S3Bucket } from './s3-bucket'
|
|
5
|
+
|
|
6
|
+
class NullBucket implements BeechBucket {
|
|
7
|
+
private fail(): never {
|
|
8
|
+
throw new Error('No storage provider configured. Set MEDIA_BUCKET or R2 credentials.')
|
|
9
|
+
}
|
|
10
|
+
put(_key: string, _body: ArrayBuffer | Uint8Array | ReadableStream, _options?: PutBucketOptions): Promise<void> { return this.fail() }
|
|
11
|
+
get(_key: string): Promise<GetBucketResult | null> { return this.fail() }
|
|
12
|
+
delete(_key: string): Promise<void> { return this.fail() }
|
|
13
|
+
head(_key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> { return this.fail() }
|
|
14
|
+
getUrl(_key: string): string { return this.fail() }
|
|
15
|
+
getTotalSize(): Promise<number> { return this.fail() }
|
|
16
|
+
list(_options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> { return this.fail() }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates the appropriate BeechBucket based on available bindings/credentials.
|
|
21
|
+
* Uses capability detection: R2 binding takes precedence over S3-compatible credentials.
|
|
22
|
+
*/
|
|
23
|
+
export function createBucketProvider(env: Env, baseUrl: string): BeechBucket {
|
|
24
|
+
if (env.MEDIA_BUCKET) {
|
|
25
|
+
return new R2BindingBucket(env.MEDIA_BUCKET, baseUrl)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ENDPOINT && env.R2_BUCKET_NAME) {
|
|
29
|
+
return new S3Bucket({
|
|
30
|
+
endpoint: env.R2_ENDPOINT,
|
|
31
|
+
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
32
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
33
|
+
bucketName: env.R2_BUCKET_NAME,
|
|
34
|
+
baseUrl,
|
|
35
|
+
cdnUrl: env.MEDIA_CDN_URL?.trim().replace(/\/$/, '') || undefined,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new NullBucket()
|
|
40
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Implementation of BeechBucket using Cloudflare R2 native binding.
|
|
5
|
+
* Used for local development (Miniflare) or native Cloudflare Workers deployment.
|
|
6
|
+
*/
|
|
7
|
+
export class R2BindingBucket implements BeechBucket {
|
|
8
|
+
constructor(
|
|
9
|
+
private bucket: R2Bucket,
|
|
10
|
+
private baseUrl: string
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
|
|
14
|
+
await this.bucket.put(key, body, {
|
|
15
|
+
httpMetadata: { contentType: options?.contentType },
|
|
16
|
+
customMetadata: options?.metadata
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async get(key: string): Promise<GetBucketResult | null> {
|
|
21
|
+
const object = await this.bucket.get(key)
|
|
22
|
+
if (!object) return null
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
body: object.body,
|
|
26
|
+
contentType: object.httpMetadata?.contentType,
|
|
27
|
+
size: object.size,
|
|
28
|
+
metadata: object.customMetadata
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async delete(key: string): Promise<void> {
|
|
33
|
+
await this.bucket.delete(key)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
|
|
37
|
+
const object = await this.bucket.head(key)
|
|
38
|
+
if (!object) return null
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
size: object.size,
|
|
42
|
+
contentType: object.httpMetadata?.contentType,
|
|
43
|
+
metadata: object.customMetadata
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
getUrl(key: string): string {
|
|
48
|
+
// If baseUrl is just the origin, we use the proxied route.
|
|
49
|
+
// If it's a custom CDN/R2 domain, it might not need /api/media/
|
|
50
|
+
// but for now, we maintain the contract: the provider decides the full URL.
|
|
51
|
+
if (this.baseUrl.includes('/api/media')) {
|
|
52
|
+
return `${this.baseUrl}/${encodeURIComponent(key)}`
|
|
53
|
+
}
|
|
54
|
+
return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async getTotalSize(): Promise<number> {
|
|
58
|
+
let total = 0
|
|
59
|
+
let cursor: string | undefined
|
|
60
|
+
do {
|
|
61
|
+
const result: R2Objects = await this.bucket.list({ cursor })
|
|
62
|
+
for (const obj of result.objects) {
|
|
63
|
+
total += obj.size
|
|
64
|
+
}
|
|
65
|
+
cursor = result.truncated ? result.cursor : undefined
|
|
66
|
+
} while (cursor)
|
|
67
|
+
return total
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
|
|
71
|
+
const result = await this.bucket.list({
|
|
72
|
+
prefix: options?.prefix,
|
|
73
|
+
limit: options?.limit,
|
|
74
|
+
cursor: options?.cursor
|
|
75
|
+
})
|
|
76
|
+
return {
|
|
77
|
+
objects: result.objects.map(obj => ({ key: obj.key, size: obj.size })),
|
|
78
|
+
cursor: result.truncated ? result.cursor : undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
|
|
2
|
+
import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Implementation of BeechBucket using S3-compatible API (AWS SDK).
|
|
6
|
+
* Used for production when connecting to R2 or any other S3 provider via HTTP.
|
|
7
|
+
*/
|
|
8
|
+
export class S3Bucket implements BeechBucket {
|
|
9
|
+
private client: S3Client
|
|
10
|
+
private bucketName: string
|
|
11
|
+
private baseUrl: string
|
|
12
|
+
|
|
13
|
+
private cdnUrl: string | null
|
|
14
|
+
|
|
15
|
+
constructor(config: {
|
|
16
|
+
endpoint: string,
|
|
17
|
+
accessKeyId: string,
|
|
18
|
+
secretAccessKey: string,
|
|
19
|
+
bucketName: string,
|
|
20
|
+
baseUrl: string,
|
|
21
|
+
cdnUrl?: string,
|
|
22
|
+
}) {
|
|
23
|
+
this.client = new S3Client({
|
|
24
|
+
region: 'auto',
|
|
25
|
+
endpoint: config.endpoint,
|
|
26
|
+
credentials: {
|
|
27
|
+
accessKeyId: config.accessKeyId,
|
|
28
|
+
secretAccessKey: config.secretAccessKey,
|
|
29
|
+
},
|
|
30
|
+
forcePathStyle: true,
|
|
31
|
+
})
|
|
32
|
+
this.bucketName = config.bucketName
|
|
33
|
+
this.baseUrl = config.baseUrl
|
|
34
|
+
this.cdnUrl = config.cdnUrl ?? null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
|
|
38
|
+
const command = new PutObjectCommand({
|
|
39
|
+
Bucket: this.bucketName,
|
|
40
|
+
Key: key,
|
|
41
|
+
Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body as ArrayBuffer),
|
|
42
|
+
ContentType: options?.contentType,
|
|
43
|
+
Metadata: options?.metadata
|
|
44
|
+
})
|
|
45
|
+
await this.client.send(command)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private async streamToUint8Array(stream: ReadableStream): Promise<Uint8Array> {
|
|
49
|
+
const reader = stream.getReader()
|
|
50
|
+
const chunks: Uint8Array[] = []
|
|
51
|
+
let totalLength = 0
|
|
52
|
+
while (true) {
|
|
53
|
+
const { done, value } = await reader.read()
|
|
54
|
+
if (done) break
|
|
55
|
+
chunks.push(value)
|
|
56
|
+
totalLength += value.length
|
|
57
|
+
}
|
|
58
|
+
const result = new Uint8Array(totalLength)
|
|
59
|
+
let offset = 0
|
|
60
|
+
for (const chunk of chunks) {
|
|
61
|
+
result.set(chunk, offset)
|
|
62
|
+
offset += chunk.length
|
|
63
|
+
}
|
|
64
|
+
return result
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async get(key: string): Promise<GetBucketResult | null> {
|
|
68
|
+
try {
|
|
69
|
+
const command = new GetObjectCommand({
|
|
70
|
+
Bucket: this.bucketName,
|
|
71
|
+
Key: key
|
|
72
|
+
})
|
|
73
|
+
const response = await this.client.send(command)
|
|
74
|
+
|
|
75
|
+
if (!response.Body) return null
|
|
76
|
+
|
|
77
|
+
const rawBody = response.Body as any
|
|
78
|
+
const body: ReadableStream = typeof rawBody.transformToWebStream === 'function'
|
|
79
|
+
? rawBody.transformToWebStream()
|
|
80
|
+
: rawBody as ReadableStream
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
body,
|
|
84
|
+
contentType: response.ContentType,
|
|
85
|
+
size: response.ContentLength ?? 0,
|
|
86
|
+
metadata: response.Metadata
|
|
87
|
+
}
|
|
88
|
+
} catch (err: any) {
|
|
89
|
+
if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message === 'NoSuchKey') {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
// Rethrow other errors (AccessDenied, Timeout, etc.)
|
|
93
|
+
throw err
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async delete(key: string): Promise<void> {
|
|
98
|
+
const command = new DeleteObjectCommand({
|
|
99
|
+
Bucket: this.bucketName,
|
|
100
|
+
Key: key
|
|
101
|
+
})
|
|
102
|
+
await this.client.send(command)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
|
|
106
|
+
try {
|
|
107
|
+
const command = new HeadObjectCommand({
|
|
108
|
+
Bucket: this.bucketName,
|
|
109
|
+
Key: key
|
|
110
|
+
})
|
|
111
|
+
const response = await this.client.send(command)
|
|
112
|
+
return {
|
|
113
|
+
size: response.ContentLength ?? 0,
|
|
114
|
+
contentType: response.ContentType,
|
|
115
|
+
metadata: response.Metadata
|
|
116
|
+
}
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
if (err.name === 'NoSuchKey' || err.name === 'NotFound') return null
|
|
119
|
+
throw err
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getUrl(key: string): string {
|
|
124
|
+
if (this.cdnUrl) {
|
|
125
|
+
return `${this.cdnUrl}/${encodeURIComponent(key)}`
|
|
126
|
+
}
|
|
127
|
+
return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async getTotalSize(): Promise<number> {
|
|
131
|
+
let total = 0
|
|
132
|
+
let token: string | undefined
|
|
133
|
+
do {
|
|
134
|
+
const command = new ListObjectsV2Command({
|
|
135
|
+
Bucket: this.bucketName,
|
|
136
|
+
ContinuationToken: token
|
|
137
|
+
})
|
|
138
|
+
const response = await this.client.send(command)
|
|
139
|
+
for (const obj of response.Contents ?? []) {
|
|
140
|
+
total += obj.Size ?? 0
|
|
141
|
+
}
|
|
142
|
+
token = response.NextContinuationToken
|
|
143
|
+
} while (token)
|
|
144
|
+
return total
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
|
|
148
|
+
const command = new ListObjectsV2Command({
|
|
149
|
+
Bucket: this.bucketName,
|
|
150
|
+
Prefix: options?.prefix,
|
|
151
|
+
MaxKeys: options?.limit,
|
|
152
|
+
ContinuationToken: options?.cursor
|
|
153
|
+
})
|
|
154
|
+
const response = await this.client.send(command)
|
|
155
|
+
return {
|
|
156
|
+
objects: (response.Contents ?? []).map(obj => ({
|
|
157
|
+
key: obj.Key ?? '',
|
|
158
|
+
size: obj.Size ?? 0
|
|
159
|
+
})),
|
|
160
|
+
cursor: response.NextContinuationToken
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|