@beechcms/api 0.4.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/README.md +21 -0
- package/migrations/0000_v040_base.sql +213 -0
- package/package.json +36 -0
- package/src/auth/constants.ts +10 -0
- package/src/auth/login.ts +91 -0
- package/src/auth/refresh.ts +127 -0
- package/src/content.ts +502 -0
- package/src/factory.ts +56 -0
- package/src/features/draft/draft.handler.ts +198 -0
- package/src/features/draft/draft.test.ts +315 -0
- package/src/features/draft/index.ts +1 -0
- package/src/features/email/email.provider.ts +38 -0
- package/src/features/email/email.service.ts +80 -0
- package/src/features/email/email.types.ts +98 -0
- package/src/features/email/index.ts +28 -0
- package/src/features/email/providers/resend.ts +63 -0
- package/src/features/email/templates/password-changed.ts +59 -0
- package/src/features/email/templates/password-reset.ts +64 -0
- package/src/features/email/templates/shell.ts +93 -0
- package/src/features/notifications/index.ts +1 -0
- package/src/features/notifications/notifications.handler.ts +88 -0
- package/src/features/password-reset/index.ts +15 -0
- package/src/features/password-reset/request.ts +88 -0
- package/src/features/password-reset/reset.ts +110 -0
- package/src/features/rotate-field/index.ts +1 -0
- package/src/features/rotate-field/rotate-field.handler.ts +82 -0
- package/src/features/rotate-field/rotate-field.schema.ts +9 -0
- package/src/features/rotate-field/rotate-field.test.ts +297 -0
- package/src/features/settings/settings.handler.ts +249 -0
- package/src/features/setup/index.ts +59 -0
- package/src/features/stats/index.ts +1 -0
- package/src/features/stats/stats.handler.ts +395 -0
- package/src/index.ts +344 -0
- package/src/media-utils.ts +78 -0
- package/src/middleware.ts +67 -0
- package/src/public/access-policy.ts +23 -0
- package/src/public/api-key-middleware.ts +53 -0
- package/src/public/index.ts +12 -0
- package/src/public/problem-details.ts +42 -0
- package/src/public/public-add.ts +183 -0
- package/src/public/public-edit.ts +183 -0
- package/src/public/public-errors.ts +15 -0
- package/src/public/public-read.ts +217 -0
- package/src/public/public-routes.ts +31 -0
- package/src/public/query-builder.ts +241 -0
- package/src/public/rate-limit-middleware.ts +42 -0
- package/src/public/response-builder.ts +26 -0
- package/src/public/sanitize.ts +65 -0
- package/src/public/slug-utils.ts +14 -0
- package/src/search-utils.ts +188 -0
- package/src/search.ts +72 -0
- package/src/shared/activity-logger.ts +79 -0
- package/src/shared/apply-policies.ts +63 -0
- package/src/shared/content-utils.ts +108 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/notification-service.ts +56 -0
- package/src/shared/query-utils.ts +137 -0
- package/src/shared/storage-utils.ts +36 -0
- package/src/types.ts +35 -0
- package/src/upload.ts +335 -0
- package/src/widget.ts +349 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
|
|
4
|
+
export type EntityType = 'content' | 'media'
|
|
5
|
+
|
|
6
|
+
export interface ActivityLogParams {
|
|
7
|
+
action: ActivityAction
|
|
8
|
+
entityType: EntityType
|
|
9
|
+
entityId: string
|
|
10
|
+
entitySlug?: string
|
|
11
|
+
details?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Logga un'azione utente asincronamente nel database D1.
|
|
16
|
+
* Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
|
|
17
|
+
*/
|
|
18
|
+
export function logActivity(
|
|
19
|
+
c: Context<any>,
|
|
20
|
+
params: ActivityLogParams
|
|
21
|
+
): void {
|
|
22
|
+
const db = c.env.DB as D1Database
|
|
23
|
+
const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
|
|
24
|
+
|
|
25
|
+
if (!db || !user) return
|
|
26
|
+
|
|
27
|
+
const { action, entityType, entityId, entitySlug, details } = params
|
|
28
|
+
const id = crypto.randomUUID()
|
|
29
|
+
|
|
30
|
+
// Usa waitUntil per non bloccare la risposta al client
|
|
31
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
32
|
+
try {
|
|
33
|
+
executionCtx = c.executionCtx
|
|
34
|
+
} catch {
|
|
35
|
+
// In ambiente di test Hono lancia se non presente
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (executionCtx) {
|
|
39
|
+
executionCtx.waitUntil((async () => {
|
|
40
|
+
try {
|
|
41
|
+
await db.prepare(
|
|
42
|
+
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
44
|
+
).bind(
|
|
45
|
+
id,
|
|
46
|
+
user.sub,
|
|
47
|
+
user.email || 'unknown',
|
|
48
|
+
user.name || null,
|
|
49
|
+
action,
|
|
50
|
+
entityType,
|
|
51
|
+
entityId,
|
|
52
|
+
entitySlug || null,
|
|
53
|
+
details ? JSON.stringify(details) : null
|
|
54
|
+
).run()
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('Failed to log activity:', err)
|
|
57
|
+
}
|
|
58
|
+
})())
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
db.prepare(
|
|
62
|
+
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
64
|
+
).bind(
|
|
65
|
+
id,
|
|
66
|
+
user.sub,
|
|
67
|
+
user.email || 'unknown',
|
|
68
|
+
user.name || null,
|
|
69
|
+
action,
|
|
70
|
+
entityType,
|
|
71
|
+
entityId,
|
|
72
|
+
entitySlug || null,
|
|
73
|
+
details ? JSON.stringify(details) : null
|
|
74
|
+
).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('Failed to log activity (sync fallback):', err)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { resolvePolicies, sha256hex } from '@beechcms/core'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
|
|
5
|
+
class PrivacyPolicyError extends Error {
|
|
6
|
+
readonly status = 501 as const
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'PrivacyPolicyError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { PrivacyPolicyError }
|
|
14
|
+
|
|
15
|
+
/** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
|
|
16
|
+
export async function applyPrivacy(
|
|
17
|
+
data: Record<string, unknown>,
|
|
18
|
+
seed: Seed,
|
|
19
|
+
): Promise<Record<string, unknown>> {
|
|
20
|
+
const result: Record<string, unknown> = {}
|
|
21
|
+
for (const [alias, value] of Object.entries(data)) {
|
|
22
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
23
|
+
if (!branch) {
|
|
24
|
+
result[alias] = value
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
const { privacy } = resolvePolicies(branch)
|
|
28
|
+
if (privacy === 'encrypt') {
|
|
29
|
+
throw new PrivacyPolicyError(
|
|
30
|
+
`Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
if (privacy === 'hash' && value != null) {
|
|
34
|
+
result[alias] = await sha256hex(String(value))
|
|
35
|
+
} else {
|
|
36
|
+
result[alias] = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Applica la visibility policy ai campi del payload in uscita verso il client. */
|
|
43
|
+
export function applyVisibility(
|
|
44
|
+
data: Record<string, unknown>,
|
|
45
|
+
seed: Seed,
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const result: Record<string, unknown> = {}
|
|
48
|
+
for (const [alias, value] of Object.entries(data)) {
|
|
49
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
50
|
+
if (!branch) {
|
|
51
|
+
result[alias] = value
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const { visibility } = resolvePolicies(branch)
|
|
55
|
+
if (visibility === 'hidden') continue
|
|
56
|
+
if (visibility === 'masked') {
|
|
57
|
+
result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
|
|
58
|
+
} else {
|
|
59
|
+
result[alias] = value
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result
|
|
63
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { deserializeFromDb, serializeForDb } from '@beechcms/core'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
import type { ContentEntry } from './query-utils'
|
|
5
|
+
|
|
6
|
+
/** Deserializza ogni branch colonna di un DB row in formato API alias. */
|
|
7
|
+
export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
|
|
8
|
+
const data: Record<string, unknown> = {}
|
|
9
|
+
for (const branch of seed.branches) {
|
|
10
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
|
|
11
|
+
}
|
|
12
|
+
return data
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
|
|
16
|
+
export function rowToEntry(
|
|
17
|
+
seed: Seed,
|
|
18
|
+
row: Record<string, unknown>,
|
|
19
|
+
hasPendingDraft = false
|
|
20
|
+
): ContentEntry {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id as string,
|
|
23
|
+
schema_slug: seed.slug,
|
|
24
|
+
slug: (row.slug as string | null) ?? null,
|
|
25
|
+
status: (row.status as string) ?? 'draft',
|
|
26
|
+
data: rowToApiData(seed, row),
|
|
27
|
+
hasPendingDraft,
|
|
28
|
+
created_at: (row.created_at as number | null) ?? null,
|
|
29
|
+
updated_at: (row.updated_at as number | null) ?? null,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InsertBindings {
|
|
34
|
+
cols: string[]
|
|
35
|
+
placeholders: string[]
|
|
36
|
+
bindings: (string | number | null)[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
|
|
40
|
+
export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
|
|
41
|
+
const cols: string[] = []
|
|
42
|
+
const placeholders: string[] = []
|
|
43
|
+
const bindings: (string | number | null)[] = []
|
|
44
|
+
for (const branch of seed.branches) {
|
|
45
|
+
if (Object.hasOwn(payload, branch.alias)) {
|
|
46
|
+
cols.push(branch.alias)
|
|
47
|
+
placeholders.push('?')
|
|
48
|
+
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { cols, placeholders, bindings }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface UpdateBindings {
|
|
55
|
+
setClause: string
|
|
56
|
+
bindings: (string | number | null)[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Costruisce SET clause e bindings per UPDATE content_{slug}. */
|
|
60
|
+
export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
|
|
61
|
+
const setParts: string[] = []
|
|
62
|
+
const bindings: (string | number | null)[] = []
|
|
63
|
+
for (const branch of seed.branches) {
|
|
64
|
+
if (Object.hasOwn(payload, branch.alias)) {
|
|
65
|
+
setParts.push(`${branch.alias} = ?`)
|
|
66
|
+
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { setClause: setParts.join(', '), bindings }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Controlla se esiste un draft pendente in content_{slug}_drafts. */
|
|
73
|
+
export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
|
|
74
|
+
if (!seed.allowDrafts) return false
|
|
75
|
+
const row = await db
|
|
76
|
+
.prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
|
|
77
|
+
.bind(entryId)
|
|
78
|
+
.first()
|
|
79
|
+
return row !== null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Scrive un evento CRUD in content_event_log per l'activity feed. */
|
|
83
|
+
export async function logContentEvent(
|
|
84
|
+
db: D1Database,
|
|
85
|
+
opts: {
|
|
86
|
+
action: 'create' | 'update' | 'delete'
|
|
87
|
+
schemaSlug: string
|
|
88
|
+
entryId: string
|
|
89
|
+
userId?: string | null
|
|
90
|
+
details?: Record<string, unknown>
|
|
91
|
+
}
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
await db
|
|
94
|
+
.prepare(
|
|
95
|
+
`INSERT INTO content_event_log (id, schema_slug, entry_id, action, user_id, details, created_at)
|
|
96
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
97
|
+
)
|
|
98
|
+
.bind(
|
|
99
|
+
crypto.randomUUID(),
|
|
100
|
+
opts.schemaSlug,
|
|
101
|
+
opts.entryId,
|
|
102
|
+
opts.action,
|
|
103
|
+
opts.userId ?? null,
|
|
104
|
+
opts.details ? JSON.stringify(opts.details) : null,
|
|
105
|
+
Math.floor(Date.now() / 1000)
|
|
106
|
+
)
|
|
107
|
+
.run()
|
|
108
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export interface NotificationParams {
|
|
4
|
+
title: string
|
|
5
|
+
message: string
|
|
6
|
+
type?: 'info' | 'success' | 'warning' | 'error'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Crea una notifica persistente nel database D1.
|
|
11
|
+
* Usata principalmente per segnalare azioni provenienti dalle Public API.
|
|
12
|
+
*/
|
|
13
|
+
export async function createNotification(
|
|
14
|
+
c: Context<any>,
|
|
15
|
+
params: NotificationParams
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const db = c.env.DB as D1Database
|
|
18
|
+
if (!db) return
|
|
19
|
+
|
|
20
|
+
const { title, message, type = 'info' } = params
|
|
21
|
+
const id = crypto.randomUUID()
|
|
22
|
+
|
|
23
|
+
// Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
|
|
24
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
25
|
+
try {
|
|
26
|
+
executionCtx = c.executionCtx
|
|
27
|
+
} catch {
|
|
28
|
+
// In ambiente di test Hono lancia se non presente
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (executionCtx) {
|
|
32
|
+
executionCtx.waitUntil((async () => {
|
|
33
|
+
try {
|
|
34
|
+
await db.prepare(
|
|
35
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
36
|
+
VALUES (?, ?, ?, ?)`
|
|
37
|
+
).bind(id, title, message, type).run()
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('Failed to create notification:', err)
|
|
40
|
+
}
|
|
41
|
+
})())
|
|
42
|
+
} else {
|
|
43
|
+
// Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
|
|
44
|
+
// Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
|
|
45
|
+
// ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
|
|
46
|
+
// Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
|
|
47
|
+
try {
|
|
48
|
+
await db.prepare(
|
|
49
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
50
|
+
VALUES (?, ?, ?, ?)`
|
|
51
|
+
).bind(id, title, message, type).run()
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('Failed to create notification (sync fallback):', err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { S3Client, ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calcola la dimensione totale occupata in un bucket R2 (in byte).
|
|
5
|
+
* Nota: esegue una scansione completa del bucket. Per bucket molto grandi
|
|
6
|
+
* andrebbe implementata una cache o usato Cloudflare Logpush/Analytics.
|
|
7
|
+
*/
|
|
8
|
+
export async function getBucketSize(client: S3Client, bucketName: string): Promise<number> {
|
|
9
|
+
let totalSize = 0
|
|
10
|
+
let isTruncatedFlag = true
|
|
11
|
+
let continuationToken: string | undefined = undefined
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
while (isTruncatedFlag) {
|
|
15
|
+
const command = new ListObjectsV2Command({
|
|
16
|
+
Bucket: bucketName,
|
|
17
|
+
ContinuationToken: continuationToken,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const response = (await client.send(command)) as ListObjectsV2CommandOutput
|
|
21
|
+
|
|
22
|
+
if (response.Contents) {
|
|
23
|
+
for (const obj of response.Contents) {
|
|
24
|
+
totalSize += obj.Size ?? 0
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
isTruncatedFlag = response.IsTruncated ?? false
|
|
29
|
+
continuationToken = response.NextContinuationToken
|
|
30
|
+
}
|
|
31
|
+
return totalSize
|
|
32
|
+
} catch (err) {
|
|
33
|
+
console.error('Error calculating bucket size:', err)
|
|
34
|
+
return 0
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { Seed } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
export interface Env {
|
|
5
|
+
DB: D1Database
|
|
6
|
+
JWT_SECRET: string
|
|
7
|
+
JWT_ISSUER?: string
|
|
8
|
+
JWT_AUDIENCE?: string
|
|
9
|
+
R2_ACCESS_KEY_ID?: string
|
|
10
|
+
R2_SECRET_ACCESS_KEY?: string
|
|
11
|
+
R2_ENDPOINT?: string
|
|
12
|
+
R2_BUCKET_NAME?: string
|
|
13
|
+
LOGIN_RATE_LIMITER?: RateLimit
|
|
14
|
+
REFRESH_RATE_LIMITER?: RateLimit
|
|
15
|
+
PUBLIC_READ_RATE_LIMITER?: RateLimit
|
|
16
|
+
PUBLIC_WRITE_RATE_LIMITER?: RateLimit
|
|
17
|
+
CORS_ORIGINS?: string
|
|
18
|
+
PUBLIC_READ_API_KEY?: string
|
|
19
|
+
PUBLIC_WRITE_API_KEY?: string
|
|
20
|
+
PUBLIC_PUBLISHED_ONLY?: string
|
|
21
|
+
PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
|
|
22
|
+
MEDIA_BASE_URL?: string
|
|
23
|
+
RESEND_API_KEY?: string
|
|
24
|
+
APP_URL?: string
|
|
25
|
+
EMAIL_FROM?: string
|
|
26
|
+
FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
|
|
27
|
+
RESET_PASSWORD_RATE_LIMITER?: RateLimit
|
|
28
|
+
ENV?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Variables {
|
|
32
|
+
jwtPayload: { sub: string; email?: string }
|
|
33
|
+
getSeed: (slug: string) => Seed | null
|
|
34
|
+
seedRegistry: Record<string, Seed>
|
|
35
|
+
}
|