@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,241 @@
|
|
|
1
|
+
import type { Seed } from '@beechcms/core'
|
|
2
|
+
import { parsePositiveInt } from '../shared/query-utils'
|
|
3
|
+
|
|
4
|
+
export type PublicQueryInput = {
|
|
5
|
+
page?: string
|
|
6
|
+
limit?: string
|
|
7
|
+
latest?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type PublicFilterLogic = 'AND' | 'OR'
|
|
11
|
+
type PublicFilterOperator =
|
|
12
|
+
| 'eq'
|
|
13
|
+
| 'neq'
|
|
14
|
+
| 'gt'
|
|
15
|
+
| 'gte'
|
|
16
|
+
| 'lt'
|
|
17
|
+
| 'lte'
|
|
18
|
+
| 'contains'
|
|
19
|
+
| 'not_contains'
|
|
20
|
+
| 'starts_with'
|
|
21
|
+
| 'ends_with'
|
|
22
|
+
| 'is_empty'
|
|
23
|
+
| 'is_not_empty'
|
|
24
|
+
| 'in'
|
|
25
|
+
| 'not_in'
|
|
26
|
+
| 'has_tag'
|
|
27
|
+
| 'has_any_tag'
|
|
28
|
+
| 'has_all_tags'
|
|
29
|
+
|
|
30
|
+
type PublicFilterCondition = {
|
|
31
|
+
field: string
|
|
32
|
+
op: PublicFilterOperator
|
|
33
|
+
value?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ParsedPublicFilter = {
|
|
37
|
+
where: PublicFilterCondition[]
|
|
38
|
+
logic: PublicFilterLogic
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const PUBLIC_FILTER_OPERATORS = new Set<PublicFilterOperator>([
|
|
42
|
+
'eq', 'neq', 'gt', 'gte', 'lt', 'lte',
|
|
43
|
+
'contains', 'not_contains', 'starts_with', 'ends_with',
|
|
44
|
+
'is_empty', 'is_not_empty', 'in', 'not_in',
|
|
45
|
+
'has_tag', 'has_any_tag', 'has_all_tags',
|
|
46
|
+
])
|
|
47
|
+
|
|
48
|
+
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
49
|
+
|
|
50
|
+
function asString(value: unknown): string | null {
|
|
51
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Risolve il nome colonna SQL per un campo.
|
|
56
|
+
* In v0.4.0 il nome colonna = branch.alias (nessun json_extract).
|
|
57
|
+
*/
|
|
58
|
+
function resolveFieldExpression(
|
|
59
|
+
seed: Seed | null,
|
|
60
|
+
field: string
|
|
61
|
+
): { expr: string; fieldType: string } | null {
|
|
62
|
+
if (SYSTEM_COLUMNS.has(field)) {
|
|
63
|
+
const type = field === 'created_at' || field === 'updated_at' ? 'number' : 'text'
|
|
64
|
+
return { expr: field, fieldType: type }
|
|
65
|
+
}
|
|
66
|
+
const branch = seed?.branches.find((b) => b.alias === field)
|
|
67
|
+
if (!branch) return null
|
|
68
|
+
return { expr: branch.alias, fieldType: branch.type }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function validateLogic(logicRaw: unknown): PublicFilterLogic {
|
|
72
|
+
if (logicRaw === undefined) return 'AND'
|
|
73
|
+
if (typeof logicRaw !== 'string') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
74
|
+
const normalized = logicRaw.toUpperCase()
|
|
75
|
+
if (normalized !== 'AND' && normalized !== 'OR') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
76
|
+
return normalized
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseWhereCondition(raw: unknown): PublicFilterCondition | null {
|
|
80
|
+
if (!raw || typeof raw !== 'object') return null
|
|
81
|
+
const maybe = raw as Record<string, unknown>
|
|
82
|
+
const field = asString(maybe.field)
|
|
83
|
+
const opRaw = asString(maybe.op)
|
|
84
|
+
if (!field || !opRaw || !PUBLIC_FILTER_OPERATORS.has(opRaw as PublicFilterOperator)) {
|
|
85
|
+
throw new TypeError(`Invalid filter: unknown operator '${opRaw ?? 'undefined'}'`)
|
|
86
|
+
}
|
|
87
|
+
return { field, op: opRaw as PublicFilterOperator, value: maybe.value }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parsePublicFilter(raw: string | undefined): ParsedPublicFilter | null {
|
|
91
|
+
if (!raw) return null
|
|
92
|
+
let parsed: unknown
|
|
93
|
+
try {
|
|
94
|
+
parsed = JSON.parse(raw)
|
|
95
|
+
} catch {
|
|
96
|
+
throw new TypeError('Invalid filter: malformed JSON')
|
|
97
|
+
}
|
|
98
|
+
if (!parsed || typeof parsed !== 'object') throw new TypeError('Invalid filter: object expected')
|
|
99
|
+
const filterObj = parsed as Record<string, unknown>
|
|
100
|
+
const logic = validateLogic(filterObj.logic)
|
|
101
|
+
if (!Array.isArray(filterObj.where)) throw new TypeError("Invalid filter: 'where' must be an array")
|
|
102
|
+
const where = filterObj.where
|
|
103
|
+
.map(parseWhereCondition)
|
|
104
|
+
.filter((item): item is PublicFilterCondition => item !== null)
|
|
105
|
+
return { where, logic }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function ensureValueArray(value: unknown, op: PublicFilterOperator, field: string): unknown[] {
|
|
109
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
110
|
+
throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a non-empty array`)
|
|
111
|
+
}
|
|
112
|
+
return value
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildTextOperation(
|
|
116
|
+
op: PublicFilterOperator,
|
|
117
|
+
expr: string,
|
|
118
|
+
field: string,
|
|
119
|
+
value: unknown
|
|
120
|
+
): { clause: string; bindings: Array<string | number> } {
|
|
121
|
+
const str = asString(value)
|
|
122
|
+
if (!str) throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a string value`)
|
|
123
|
+
if (op === 'contains') return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`%${str}%`] }
|
|
124
|
+
if (op === 'not_contains') return { clause: `LOWER(CAST(${expr} AS TEXT)) NOT LIKE LOWER(?)`, bindings: [`%${str}%`] }
|
|
125
|
+
if (op === 'starts_with') return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`${str}%`] }
|
|
126
|
+
return { clause: `LOWER(CAST(${expr} AS TEXT)) LIKE LOWER(?)`, bindings: [`%${str}`] }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildSetOperation(
|
|
130
|
+
op: PublicFilterOperator,
|
|
131
|
+
expr: string,
|
|
132
|
+
field: string,
|
|
133
|
+
value: unknown
|
|
134
|
+
): { clause: string; bindings: Array<string | number> } {
|
|
135
|
+
const values = ensureValueArray(value, op, field)
|
|
136
|
+
const placeholders = values.map(() => '?').join(',')
|
|
137
|
+
return {
|
|
138
|
+
clause: `${expr} ${op === 'in' ? 'IN' : 'NOT IN'} (${placeholders})`,
|
|
139
|
+
bindings: values as Array<string | number>,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildTagOperation(
|
|
144
|
+
op: PublicFilterOperator,
|
|
145
|
+
expr: string,
|
|
146
|
+
field: string,
|
|
147
|
+
value: unknown
|
|
148
|
+
): { clause: string; bindings: Array<string | number> } {
|
|
149
|
+
const tags = op === 'has_tag' ? [value] : ensureValueArray(value, op, field)
|
|
150
|
+
const cleaned = tags.map((item) => asString(item)).filter((item): item is string => item !== null)
|
|
151
|
+
if (cleaned.length === 0) {
|
|
152
|
+
throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires tag string values`)
|
|
153
|
+
}
|
|
154
|
+
if (op === 'has_tag' || op === 'has_any_tag') {
|
|
155
|
+
const placeholders = cleaned.map(() => '?').join(',')
|
|
156
|
+
return {
|
|
157
|
+
clause: `EXISTS (SELECT 1 FROM json_each(${expr}) je WHERE CAST(je.value AS TEXT) IN (${placeholders}))`,
|
|
158
|
+
bindings: cleaned,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const allParts = cleaned.map(() => `EXISTS (SELECT 1 FROM json_each(${expr}) je WHERE CAST(je.value AS TEXT) = ?)`)
|
|
162
|
+
return { clause: allParts.join(' AND '), bindings: cleaned }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildConditionClause(
|
|
166
|
+
condition: PublicFilterCondition,
|
|
167
|
+
seed: Seed | null
|
|
168
|
+
): { clause: string; bindings: Array<string | number> } | null {
|
|
169
|
+
const fieldMeta = resolveFieldExpression(seed, condition.field)
|
|
170
|
+
if (!fieldMeta) return null
|
|
171
|
+
const { expr, fieldType } = fieldMeta
|
|
172
|
+
const { op, value, field } = condition
|
|
173
|
+
|
|
174
|
+
if (op === 'is_empty') return { clause: `(${expr} IS NULL OR TRIM(CAST(${expr} AS TEXT)) = '')`, bindings: [] }
|
|
175
|
+
if (op === 'is_not_empty') return { clause: `(${expr} IS NOT NULL AND TRIM(CAST(${expr} AS TEXT)) <> '')`, bindings: [] }
|
|
176
|
+
if (op === 'contains' || op === 'not_contains' || op === 'starts_with' || op === 'ends_with') {
|
|
177
|
+
return buildTextOperation(op, expr, field, value)
|
|
178
|
+
}
|
|
179
|
+
if (op === 'in' || op === 'not_in') return buildSetOperation(op, expr, field, value)
|
|
180
|
+
if (op === 'has_tag' || op === 'has_any_tag' || op === 'has_all_tags') {
|
|
181
|
+
if (fieldType !== 'json') throw new TypeError(`Invalid filter: operator '${op}' requires a json field`)
|
|
182
|
+
return buildTagOperation(op, expr, field, value)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (op === 'eq' || op === 'neq') {
|
|
186
|
+
if (typeof value === 'boolean') {
|
|
187
|
+
return { clause: `${expr} ${op === 'eq' ? '=' : '!='} ?`, bindings: [value ? 1 : 0] }
|
|
188
|
+
}
|
|
189
|
+
if (typeof value === 'number' && !Number.isNaN(value)) {
|
|
190
|
+
return { clause: `${expr} ${op === 'eq' ? '=' : '!='} ?`, bindings: [value] }
|
|
191
|
+
}
|
|
192
|
+
const textValue = asString(value)
|
|
193
|
+
if (!textValue) throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a scalar value`)
|
|
194
|
+
return {
|
|
195
|
+
clause: `LOWER(TRIM(CAST(${expr} AS TEXT))) ${op === 'eq' ? '=' : '!='} LOWER(TRIM(?))`,
|
|
196
|
+
bindings: [textValue],
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const numberValue = typeof value === 'number' && !Number.isNaN(value) ? value : null
|
|
201
|
+
const textValue = asString(value)
|
|
202
|
+
const mathOp = op === 'gt' ? '>' : op === 'gte' ? '>=' : op === 'lt' ? '<' : op === 'lte' ? '<=' : null
|
|
203
|
+
if (!mathOp) throw new TypeError(`Invalid filter: operator '${op}' is not supported`)
|
|
204
|
+
if ((fieldType === 'number' || field === 'created_at' || field === 'updated_at') && numberValue !== null) {
|
|
205
|
+
return { clause: `${expr} ${mathOp} ?`, bindings: [numberValue] }
|
|
206
|
+
}
|
|
207
|
+
if (textValue) return { clause: `CAST(${expr} AS TEXT) ${mathOp} ?`, bindings: [textValue] }
|
|
208
|
+
throw new TypeError(`Invalid filter: operator '${op}' for field '${field}' requires a compatible value`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function buildPublicFilterWhereClause(
|
|
212
|
+
seed: Seed | null,
|
|
213
|
+
parsedFilter: ParsedPublicFilter | null
|
|
214
|
+
): { clause: string; bindings: Array<string | number> } {
|
|
215
|
+
if (!parsedFilter || parsedFilter.where.length === 0) return { clause: '', bindings: [] }
|
|
216
|
+
const clauses: string[] = []
|
|
217
|
+
const bindings: Array<string | number> = []
|
|
218
|
+
for (const condition of parsedFilter.where) {
|
|
219
|
+
const built = buildConditionClause(condition, seed)
|
|
220
|
+
if (!built) continue
|
|
221
|
+
clauses.push(`(${built.clause})`)
|
|
222
|
+
bindings.push(...built.bindings)
|
|
223
|
+
}
|
|
224
|
+
if (clauses.length === 0) return { clause: '', bindings: [] }
|
|
225
|
+
return { clause: clauses.join(` ${parsedFilter.logic} `), bindings }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function parsePublicPagination(input: PublicQueryInput): { page: number; limit: number } {
|
|
229
|
+
const page = parsePositiveInt(input.page, 1)
|
|
230
|
+
const limit = Math.min(parsePositiveInt(input.limit, 25), 100)
|
|
231
|
+
return { page, limit }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function parseLatestCount(latest: string | undefined): number {
|
|
235
|
+
if (!latest) return 10
|
|
236
|
+
const raw = Number.parseInt(latest, 10)
|
|
237
|
+
const parsed = Number.isNaN(raw) ? 10 : raw
|
|
238
|
+
if (parsed < 1) return 1
|
|
239
|
+
if (parsed > 100) return 100
|
|
240
|
+
return parsed
|
|
241
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono'
|
|
2
|
+
import { publicProblem } from './problem-details'
|
|
3
|
+
|
|
4
|
+
type PublicBindings = {
|
|
5
|
+
PUBLIC_READ_RATE_LIMITER?: RateLimit
|
|
6
|
+
PUBLIC_WRITE_RATE_LIMITER?: RateLimit
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function isReadMethod(method: string): boolean {
|
|
10
|
+
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getClientIp(headers: Headers): string {
|
|
14
|
+
return headers.get('cf-connecting-ip') ?? 'unknown'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function publicRateLimitMiddleware() {
|
|
18
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
19
|
+
const env = c.env as PublicBindings
|
|
20
|
+
const readMethod = isReadMethod(c.req.method)
|
|
21
|
+
const limiter = readMethod ? env.PUBLIC_READ_RATE_LIMITER : env.PUBLIC_WRITE_RATE_LIMITER
|
|
22
|
+
if (!limiter) {
|
|
23
|
+
await next()
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const seed = c.req.param('seed') ?? 'no-seed'
|
|
28
|
+
const key = `${getClientIp(c.req.raw.headers)}:${seed}:${readMethod ? 'read' : 'write'}`
|
|
29
|
+
const { success } = await limiter.limit({ key })
|
|
30
|
+
|
|
31
|
+
if (!success) {
|
|
32
|
+
return publicProblem(c, {
|
|
33
|
+
type: 'rate-limit-exceeded',
|
|
34
|
+
title: 'Too Many Requests',
|
|
35
|
+
status: 429,
|
|
36
|
+
detail: 'Too many requests',
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await next()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper meta per risposta lista Public API.
|
|
3
|
+
*/
|
|
4
|
+
export function buildPublicListMeta(input: {
|
|
5
|
+
total: number
|
|
6
|
+
page: number
|
|
7
|
+
limit: number
|
|
8
|
+
returned: number
|
|
9
|
+
seed: string
|
|
10
|
+
}) {
|
|
11
|
+
return {
|
|
12
|
+
total: input.total,
|
|
13
|
+
page: input.page,
|
|
14
|
+
limit: input.limit,
|
|
15
|
+
returned: input.returned,
|
|
16
|
+
seed: input.seed,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Helper meta per risposta singolo elemento Public API.
|
|
22
|
+
*/
|
|
23
|
+
export function buildPublicSingleMeta(seed: string) {
|
|
24
|
+
return { seed }
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { validateAndSanitizeSeedPayload } from '@beechcms/core'
|
|
2
|
+
import type { Seed, ValidationDetail } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
type PublicSanitizeSuccess = {
|
|
5
|
+
ok: true
|
|
6
|
+
data: Record<string, unknown>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type PublicSanitizeFailure = {
|
|
10
|
+
ok: false
|
|
11
|
+
status: 400 | 422
|
|
12
|
+
code: 'validation_failed' | 'dangerous_content'
|
|
13
|
+
message: string
|
|
14
|
+
details?: ValidationDetail[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type PublicSanitizeResult = PublicSanitizeSuccess | PublicSanitizeFailure
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Adapter Public API: usa la foundation del core e mappa errori nel formato sprint.
|
|
21
|
+
*/
|
|
22
|
+
export function sanitizePublicPayload(
|
|
23
|
+
seed: Seed,
|
|
24
|
+
payload: Record<string, unknown>,
|
|
25
|
+
options: {
|
|
26
|
+
allowNull?: boolean
|
|
27
|
+
operation?: 'create' | 'update'
|
|
28
|
+
requireAtLeastOneValidField?: boolean
|
|
29
|
+
enforceRequiredFields?: boolean
|
|
30
|
+
} = {}
|
|
31
|
+
): PublicSanitizeResult {
|
|
32
|
+
const operation = options.operation ?? 'create'
|
|
33
|
+
const result = validateAndSanitizeSeedPayload(seed, payload, {
|
|
34
|
+
allowNull: options.allowNull ?? false,
|
|
35
|
+
operation,
|
|
36
|
+
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
37
|
+
enforceRequiredFields: options.enforceRequiredFields ?? true,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (result.dangerousFields.length > 0) {
|
|
41
|
+
const field = result.dangerousFields[0]
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
status: 422,
|
|
45
|
+
code: 'dangerous_content',
|
|
46
|
+
message: `Content rejected: dangerous markup detected in field '${field}'`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (result.details.length > 0) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
status: 400,
|
|
54
|
+
code: 'validation_failed',
|
|
55
|
+
message: 'Validation failed',
|
|
56
|
+
details: result.details,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ok: true,
|
|
62
|
+
data: result.data,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converte stringa in slug URL-safe.
|
|
5
|
+
* Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
|
|
6
|
+
*/
|
|
7
|
+
export { slugify }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Genera slug da title/name o fallback UUID-like.
|
|
11
|
+
* Logica spostata in @beechcms/core per consistenza tra Dashboard e API.
|
|
12
|
+
*/
|
|
13
|
+
export { generateEntrySlug }
|
|
14
|
+
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// apps/api/src/search-utils.ts
|
|
2
|
+
// Pure functions — zero Hono dependencies, importable from Vitest.
|
|
3
|
+
// v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
|
|
4
|
+
|
|
5
|
+
import type { Seed } from "@beechcms/core"
|
|
6
|
+
|
|
7
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
// Row returned by UNION ALL query across fts_{slug} JOIN content_{slug}
|
|
10
|
+
export interface FtsRow {
|
|
11
|
+
entry_id: string
|
|
12
|
+
schema_slug: string
|
|
13
|
+
slug: string | null
|
|
14
|
+
status: string
|
|
15
|
+
title: string | null
|
|
16
|
+
excerpt: string
|
|
17
|
+
rank: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SearchResultItem {
|
|
21
|
+
id: string
|
|
22
|
+
schema_slug: string
|
|
23
|
+
slug: string | null
|
|
24
|
+
status: string
|
|
25
|
+
title: string
|
|
26
|
+
excerpt: string
|
|
27
|
+
data: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchResponse {
|
|
31
|
+
items: SearchResultItem[]
|
|
32
|
+
nextCursor: string | null
|
|
33
|
+
total: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SearchQueryParams {
|
|
37
|
+
q: string
|
|
38
|
+
schemaSlug: string | null
|
|
39
|
+
status: string | null
|
|
40
|
+
limit: number // already clamped 1–50 by handler
|
|
41
|
+
cursor: string | null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Cursor ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export function encodeCursor(rank: number, entryId: string): string {
|
|
47
|
+
return btoa(`${rank}:${entryId}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function decodeCursor(cursor: string): { rank: number; entryId: string } | null {
|
|
51
|
+
try {
|
|
52
|
+
const decoded = atob(cursor)
|
|
53
|
+
const sep = decoded.lastIndexOf(":")
|
|
54
|
+
if (sep === -1) return null
|
|
55
|
+
return {
|
|
56
|
+
rank: parseFloat(decoded.slice(0, sep)),
|
|
57
|
+
entryId: decoded.slice(sep + 1),
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function hasSearchableFts(seed: Seed): boolean {
|
|
67
|
+
return seed.branches.some(b =>
|
|
68
|
+
(b.type === 'text' || b.type === 'richtext') && b.policies?.search !== false
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildMatchExpr(q: string): string {
|
|
73
|
+
const MIN_PREFIX = 3
|
|
74
|
+
const safeQ = q.replace(/["*^()]/g, " ").trim()
|
|
75
|
+
const terms = safeQ.split(/\s+/).filter(t => t.length >= 2)
|
|
76
|
+
if (terms.length === 0) throw new Error("EMPTY_QUERY")
|
|
77
|
+
|
|
78
|
+
return terms
|
|
79
|
+
.map(t => {
|
|
80
|
+
if (/^\d+$/.test(t)) return `"${t}"`
|
|
81
|
+
if (t.length <= MIN_PREFIX) return `"${t}"*`
|
|
82
|
+
const prefixes: string[] = []
|
|
83
|
+
for (let i = MIN_PREFIX; i <= t.length; i++) {
|
|
84
|
+
prefixes.push(`"${t.slice(0, i)}"*`)
|
|
85
|
+
}
|
|
86
|
+
return `(${prefixes.join(" OR ")})`
|
|
87
|
+
})
|
|
88
|
+
.join(" ")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Query builder ───────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a UNION ALL query across all per-seed fts_{slug} tables (v0.4.0).
|
|
95
|
+
* Each SELECT joins fts_{slug} with content_{slug} to fetch title, slug, status.
|
|
96
|
+
* Seeds param: full registry — filtered internally by schemaSlug and FTS availability.
|
|
97
|
+
*/
|
|
98
|
+
export function buildFtsQuery(
|
|
99
|
+
params: SearchQueryParams,
|
|
100
|
+
seeds: Seed[],
|
|
101
|
+
): {
|
|
102
|
+
sql: string
|
|
103
|
+
binds: unknown[]
|
|
104
|
+
countSql: string
|
|
105
|
+
countBinds: unknown[]
|
|
106
|
+
} {
|
|
107
|
+
const { q, schemaSlug, status, limit, cursor } = params
|
|
108
|
+
|
|
109
|
+
const matchExpr = buildMatchExpr(q) // throws EMPTY_QUERY if needed
|
|
110
|
+
|
|
111
|
+
const targetSeeds = seeds.filter(s =>
|
|
112
|
+
hasSearchableFts(s) && (schemaSlug === null || s.slug === schemaSlug)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (targetSeeds.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
sql: "SELECT NULL as entry_id, NULL as schema_slug, NULL as slug, NULL as status, NULL as title, '' as excerpt, 0 as rank WHERE 1=0",
|
|
118
|
+
binds: [],
|
|
119
|
+
countSql: "SELECT 0 as total",
|
|
120
|
+
countBinds: [],
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const decoded = cursor ? decodeCursor(cursor) : null
|
|
125
|
+
|
|
126
|
+
const parts: string[] = []
|
|
127
|
+
const binds: unknown[] = []
|
|
128
|
+
const countParts: string[] = []
|
|
129
|
+
const countBinds: unknown[] = []
|
|
130
|
+
|
|
131
|
+
for (const seed of targetSeeds) {
|
|
132
|
+
const fts = `fts_${seed.slug}`
|
|
133
|
+
const table = `content_${seed.slug}`
|
|
134
|
+
const title = seed.displayNameAlias
|
|
135
|
+
|
|
136
|
+
// Main query per seed
|
|
137
|
+
const where: string[] = [`${fts} MATCH ?`]
|
|
138
|
+
const lb: unknown[] = [matchExpr]
|
|
139
|
+
|
|
140
|
+
if (status) { where.push("ce.status = ?"); lb.push(status) }
|
|
141
|
+
|
|
142
|
+
if (decoded) {
|
|
143
|
+
where.push(`(bm25(${fts}) > ? OR (bm25(${fts}) = ? AND f.entry_id > ?))`)
|
|
144
|
+
lb.push(decoded.rank, decoded.rank, decoded.entryId)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
parts.push(
|
|
148
|
+
`SELECT f.entry_id, '${seed.slug}' AS schema_slug, ce.slug, ce.status,` +
|
|
149
|
+
` ce.${title} AS title,` +
|
|
150
|
+
` snippet(${fts}, 1, '<mark>', '</mark>', '…', 16) AS excerpt,` +
|
|
151
|
+
` bm25(${fts}) AS rank` +
|
|
152
|
+
` FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id` +
|
|
153
|
+
` WHERE ${where.join(' AND ')}`
|
|
154
|
+
)
|
|
155
|
+
binds.push(...lb)
|
|
156
|
+
|
|
157
|
+
// Count per seed (no cursor, no limit)
|
|
158
|
+
const cw: string[] = [`${fts} MATCH ?`]
|
|
159
|
+
const cb: unknown[] = [matchExpr]
|
|
160
|
+
if (status) { cw.push("ce.status = ?"); cb.push(status) }
|
|
161
|
+
|
|
162
|
+
countParts.push(
|
|
163
|
+
`SELECT COUNT(*) as c FROM ${fts} f JOIN ${table} ce ON ce.id = f.entry_id WHERE ${cw.join(' AND ')}`
|
|
164
|
+
)
|
|
165
|
+
countBinds.push(...cb)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const sql = `${parts.join(' UNION ALL ')} ORDER BY rank, entry_id LIMIT ?`
|
|
169
|
+
binds.push(limit + 1)
|
|
170
|
+
|
|
171
|
+
const countSql = `SELECT SUM(c) as total FROM (${countParts.join(' UNION ALL ')})`
|
|
172
|
+
|
|
173
|
+
return { sql, binds, countSql, countBinds }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ─── Mapper ──────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
export function mapFtsRow(row: FtsRow): SearchResultItem {
|
|
179
|
+
return {
|
|
180
|
+
id: row.entry_id,
|
|
181
|
+
schema_slug: row.schema_slug,
|
|
182
|
+
slug: row.slug,
|
|
183
|
+
status: row.status,
|
|
184
|
+
title: row.title ?? "",
|
|
185
|
+
excerpt: row.excerpt ?? "",
|
|
186
|
+
data: {},
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// apps/api/src/search.ts
|
|
2
|
+
|
|
3
|
+
import { Hono } from "hono"
|
|
4
|
+
import type { Env, Variables } from "./types"
|
|
5
|
+
import { authMiddleware } from "./middleware"
|
|
6
|
+
import {
|
|
7
|
+
buildFtsQuery,
|
|
8
|
+
encodeCursor,
|
|
9
|
+
mapFtsRow,
|
|
10
|
+
type FtsRow,
|
|
11
|
+
type SearchResponse,
|
|
12
|
+
} from "./search-utils"
|
|
13
|
+
|
|
14
|
+
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
15
|
+
|
|
16
|
+
searchRouter.use("*", async (c, next) => {
|
|
17
|
+
return authMiddleware(c.env.JWT_SECRET, {
|
|
18
|
+
issuer: c.env.JWT_ISSUER,
|
|
19
|
+
audience: c.env.JWT_AUDIENCE,
|
|
20
|
+
})(c, next)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
24
|
+
searchRouter.get("/", async (c) => {
|
|
25
|
+
const q = c.req.query("q")?.trim() ?? ""
|
|
26
|
+
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
27
|
+
const status = c.req.query("status") ?? null
|
|
28
|
+
const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
|
|
29
|
+
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
30
|
+
const cursor = c.req.query("cursor") ?? null
|
|
31
|
+
|
|
32
|
+
if (q.length < 2) {
|
|
33
|
+
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const seeds = Object.values(c.get('seedRegistry'))
|
|
37
|
+
|
|
38
|
+
let queryParts: ReturnType<typeof buildFtsQuery>
|
|
39
|
+
try {
|
|
40
|
+
queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if ((e as Error).message === "EMPTY_QUERY") {
|
|
43
|
+
return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
|
|
44
|
+
}
|
|
45
|
+
throw e
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { sql, binds, countSql, countBinds } = queryParts
|
|
49
|
+
|
|
50
|
+
const [ftsResult, countResult] = await Promise.all([
|
|
51
|
+
c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
|
|
52
|
+
c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const rows = ftsResult.results ?? []
|
|
56
|
+
const total = countResult?.total ?? 0
|
|
57
|
+
|
|
58
|
+
const hasMore = rows.length > limit
|
|
59
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
|
60
|
+
|
|
61
|
+
const nextCursor = hasMore
|
|
62
|
+
? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
if (pageRows.length === 0) {
|
|
66
|
+
return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const items = pageRows.map(row => mapFtsRow(row))
|
|
70
|
+
|
|
71
|
+
return c.json({ items, nextCursor, total } satisfies SearchResponse)
|
|
72
|
+
})
|