@beechcms/api 0.4.0-preview.12 → 0.4.0-preview.13
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-CewtCjom.css +1 -0
- package/assets/dashboard/assets/{index-CTSuGxlX.js → index-FQ6JhvRH.js} +99 -99
- 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/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -325
- package/src/features/content/constants.ts +10 -10
- package/src/features/content/handlers/create.ts +153 -163
- package/src/features/content/handlers/delete.ts +76 -85
- package/src/features/content/handlers/facets.ts +45 -45
- package/src/features/content/handlers/get.ts +116 -116
- package/src/features/content/handlers/list.ts +88 -88
- package/src/features/content/handlers/update.ts +207 -216
- package/src/features/content/index.ts +20 -20
- package/src/features/draft/draft.handler.ts +272 -272
- 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 +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -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 +414 -267
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -24
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -18
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -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 +166 -166
- package/src/public/public-edit.ts +158 -158
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -216
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -152
- package/src/public/rate-limit-middleware.ts +42 -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/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -28
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -382
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -45
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- 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.ts +44 -0
- package/src/types.ts +46 -41
- package/src/upload.ts +179 -331
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-ye3325L9.css +0 -1
|
@@ -1,152 +1,152 @@
|
|
|
1
|
-
import type { Seed, FilterGroup, FilterOperator, FilterType, BranchType } 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
|
-
export type PublicFilterLogic = 'AND' | 'OR'
|
|
11
|
-
export 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
|
-
export type PublicFilterCondition = {
|
|
31
|
-
field: string
|
|
32
|
-
op: PublicFilterOperator
|
|
33
|
-
value?: unknown
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export 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
|
-
function asString(value: unknown): string | null {
|
|
49
|
-
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function validateLogic(logicRaw: unknown): PublicFilterLogic {
|
|
53
|
-
if (logicRaw === undefined) return 'AND'
|
|
54
|
-
if (typeof logicRaw !== 'string') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
55
|
-
const normalized = logicRaw.toUpperCase()
|
|
56
|
-
if (normalized !== 'AND' && normalized !== 'OR') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
57
|
-
return normalized
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function parseWhereCondition(raw: unknown): PublicFilterCondition | null {
|
|
61
|
-
if (!raw || typeof raw !== 'object') return null
|
|
62
|
-
const maybe = raw as Record<string, unknown>
|
|
63
|
-
const field = asString(maybe.field)
|
|
64
|
-
const opRaw = asString(maybe.op)
|
|
65
|
-
if (!field || !opRaw || !PUBLIC_FILTER_OPERATORS.has(opRaw as PublicFilterOperator)) {
|
|
66
|
-
throw new TypeError(`Invalid filter: unknown operator '${opRaw ?? 'undefined'}'`)
|
|
67
|
-
}
|
|
68
|
-
return { field, op: opRaw as PublicFilterOperator, value: maybe.value }
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function parsePublicFilter(raw: string | undefined): ParsedPublicFilter | null {
|
|
72
|
-
if (!raw) return null
|
|
73
|
-
let parsed: unknown
|
|
74
|
-
try {
|
|
75
|
-
parsed = JSON.parse(raw)
|
|
76
|
-
} catch {
|
|
77
|
-
throw new TypeError('Invalid filter: malformed JSON')
|
|
78
|
-
}
|
|
79
|
-
if (!parsed || typeof parsed !== 'object') throw new TypeError('Invalid filter: object expected')
|
|
80
|
-
const filterObj = parsed as Record<string, unknown>
|
|
81
|
-
const logic = validateLogic(filterObj.logic)
|
|
82
|
-
if (!Array.isArray(filterObj.where)) throw new TypeError("Invalid filter: 'where' must be an array")
|
|
83
|
-
const where = filterObj.where
|
|
84
|
-
.map(parseWhereCondition)
|
|
85
|
-
.filter((item): item is PublicFilterCondition => item !== null)
|
|
86
|
-
return { where, logic }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
90
|
-
|
|
91
|
-
function mapBranchToFilterType(type: BranchType): FilterType {
|
|
92
|
-
switch (type) {
|
|
93
|
-
case 'richtext':
|
|
94
|
-
case 'file':
|
|
95
|
-
return 'text'
|
|
96
|
-
case 'json':
|
|
97
|
-
return 'json'
|
|
98
|
-
case 'tags':
|
|
99
|
-
return 'tags'
|
|
100
|
-
case 'number':
|
|
101
|
-
return 'number'
|
|
102
|
-
case 'boolean':
|
|
103
|
-
return 'boolean'
|
|
104
|
-
case 'date':
|
|
105
|
-
return 'date'
|
|
106
|
-
case 'text':
|
|
107
|
-
default:
|
|
108
|
-
return 'text'
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Trasforma il filtro pubblico in FilterGroup[] per il Repository.
|
|
114
|
-
* Zero SQL: la logica di generazione query risiede esclusivamente nel Repository/Engine core.
|
|
115
|
-
*/
|
|
116
|
-
export function toEngineFilters(seed: Seed, parsedFilter: ParsedPublicFilter | null): FilterGroup[] {
|
|
117
|
-
if (!parsedFilter || parsedFilter.where.length === 0) return []
|
|
118
|
-
|
|
119
|
-
// Note: Repository currently joins groups with AND.
|
|
120
|
-
// Public API supports logic: OR but the core engine currently defaults to AND for top-level groups.
|
|
121
|
-
// We map each condition to a group for maximum compatibility with the engine's buildFilterCondition.
|
|
122
|
-
return parsedFilter.where.map((cond) => {
|
|
123
|
-
const branch = seed.branches.find(b => b.alias === cond.field)
|
|
124
|
-
const type: FilterType = branch
|
|
125
|
-
? mapBranchToFilterType(branch.type)
|
|
126
|
-
: (SYSTEM_COLUMNS.has(cond.field) ? 'system' : 'text')
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
column: cond.field,
|
|
130
|
-
type,
|
|
131
|
-
conditions: [{
|
|
132
|
-
op: cond.op as FilterOperator,
|
|
133
|
-
value: cond.value as any
|
|
134
|
-
}]
|
|
135
|
-
}
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function parsePublicPagination(input: PublicQueryInput): { page: number; limit: number } {
|
|
140
|
-
const page = parsePositiveInt(input.page, 1)
|
|
141
|
-
const limit = Math.min(parsePositiveInt(input.limit, 25), 100)
|
|
142
|
-
return { page, limit }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function parseLatestCount(latest: string | undefined): number {
|
|
146
|
-
if (!latest) return 10
|
|
147
|
-
const raw = Number.parseInt(latest, 10)
|
|
148
|
-
const parsed = Number.isNaN(raw) ? 10 : raw
|
|
149
|
-
if (parsed < 1) return 1
|
|
150
|
-
if (parsed > 100) return 100
|
|
151
|
-
return parsed
|
|
152
|
-
}
|
|
1
|
+
import type { Seed, FilterGroup, FilterOperator, FilterType, BranchType } 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
|
+
export type PublicFilterLogic = 'AND' | 'OR'
|
|
11
|
+
export 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
|
+
export type PublicFilterCondition = {
|
|
31
|
+
field: string
|
|
32
|
+
op: PublicFilterOperator
|
|
33
|
+
value?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export 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
|
+
function asString(value: unknown): string | null {
|
|
49
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function validateLogic(logicRaw: unknown): PublicFilterLogic {
|
|
53
|
+
if (logicRaw === undefined) return 'AND'
|
|
54
|
+
if (typeof logicRaw !== 'string') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
55
|
+
const normalized = logicRaw.toUpperCase()
|
|
56
|
+
if (normalized !== 'AND' && normalized !== 'OR') throw new TypeError("Invalid filter: 'logic' must be 'AND' or 'OR'")
|
|
57
|
+
return normalized
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseWhereCondition(raw: unknown): PublicFilterCondition | null {
|
|
61
|
+
if (!raw || typeof raw !== 'object') return null
|
|
62
|
+
const maybe = raw as Record<string, unknown>
|
|
63
|
+
const field = asString(maybe.field)
|
|
64
|
+
const opRaw = asString(maybe.op)
|
|
65
|
+
if (!field || !opRaw || !PUBLIC_FILTER_OPERATORS.has(opRaw as PublicFilterOperator)) {
|
|
66
|
+
throw new TypeError(`Invalid filter: unknown operator '${opRaw ?? 'undefined'}'`)
|
|
67
|
+
}
|
|
68
|
+
return { field, op: opRaw as PublicFilterOperator, value: maybe.value }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parsePublicFilter(raw: string | undefined): ParsedPublicFilter | null {
|
|
72
|
+
if (!raw) return null
|
|
73
|
+
let parsed: unknown
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(raw)
|
|
76
|
+
} catch {
|
|
77
|
+
throw new TypeError('Invalid filter: malformed JSON')
|
|
78
|
+
}
|
|
79
|
+
if (!parsed || typeof parsed !== 'object') throw new TypeError('Invalid filter: object expected')
|
|
80
|
+
const filterObj = parsed as Record<string, unknown>
|
|
81
|
+
const logic = validateLogic(filterObj.logic)
|
|
82
|
+
if (!Array.isArray(filterObj.where)) throw new TypeError("Invalid filter: 'where' must be an array")
|
|
83
|
+
const where = filterObj.where
|
|
84
|
+
.map(parseWhereCondition)
|
|
85
|
+
.filter((item): item is PublicFilterCondition => item !== null)
|
|
86
|
+
return { where, logic }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const SYSTEM_COLUMNS = new Set(['id', 'slug', 'status', 'created_at', 'updated_at'])
|
|
90
|
+
|
|
91
|
+
function mapBranchToFilterType(type: BranchType): FilterType {
|
|
92
|
+
switch (type) {
|
|
93
|
+
case 'richtext':
|
|
94
|
+
case 'file':
|
|
95
|
+
return 'text'
|
|
96
|
+
case 'json':
|
|
97
|
+
return 'json'
|
|
98
|
+
case 'tags':
|
|
99
|
+
return 'tags'
|
|
100
|
+
case 'number':
|
|
101
|
+
return 'number'
|
|
102
|
+
case 'boolean':
|
|
103
|
+
return 'boolean'
|
|
104
|
+
case 'date':
|
|
105
|
+
return 'date'
|
|
106
|
+
case 'text':
|
|
107
|
+
default:
|
|
108
|
+
return 'text'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Trasforma il filtro pubblico in FilterGroup[] per il Repository.
|
|
114
|
+
* Zero SQL: la logica di generazione query risiede esclusivamente nel Repository/Engine core.
|
|
115
|
+
*/
|
|
116
|
+
export function toEngineFilters(seed: Seed, parsedFilter: ParsedPublicFilter | null): FilterGroup[] {
|
|
117
|
+
if (!parsedFilter || parsedFilter.where.length === 0) return []
|
|
118
|
+
|
|
119
|
+
// Note: Repository currently joins groups with AND.
|
|
120
|
+
// Public API supports logic: OR but the core engine currently defaults to AND for top-level groups.
|
|
121
|
+
// We map each condition to a group for maximum compatibility with the engine's buildFilterCondition.
|
|
122
|
+
return parsedFilter.where.map((cond) => {
|
|
123
|
+
const branch = seed.branches.find(b => b.alias === cond.field)
|
|
124
|
+
const type: FilterType = branch
|
|
125
|
+
? mapBranchToFilterType(branch.type)
|
|
126
|
+
: (SYSTEM_COLUMNS.has(cond.field) ? 'system' : 'text')
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
column: cond.field,
|
|
130
|
+
type,
|
|
131
|
+
conditions: [{
|
|
132
|
+
op: cond.op as FilterOperator,
|
|
133
|
+
value: cond.value as any
|
|
134
|
+
}]
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function parsePublicPagination(input: PublicQueryInput): { page: number; limit: number } {
|
|
140
|
+
const page = parsePositiveInt(input.page, 1)
|
|
141
|
+
const limit = Math.min(parsePositiveInt(input.limit, 25), 100)
|
|
142
|
+
return { page, limit }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function parseLatestCount(latest: string | undefined): number {
|
|
146
|
+
if (!latest) return 10
|
|
147
|
+
const raw = Number.parseInt(latest, 10)
|
|
148
|
+
const parsed = Number.isNaN(raw) ? 10 : raw
|
|
149
|
+
if (parsed < 1) return 1
|
|
150
|
+
if (parsed > 100) return 100
|
|
151
|
+
return parsed
|
|
152
|
+
}
|
|
@@ -1,42 +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
|
-
}
|
|
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
|
+
}
|
|
@@ -1,26 +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
|
-
|
|
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
|
+
|
package/src/public/sanitize.ts
CHANGED
|
@@ -1,65 +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
|
-
*
|
|
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
|
-
|
|
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
|
+
* Public API Adapter: uses the core foundation and maps errors into the sprint format.
|
|
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
|
+
|
package/src/public/slug-utils.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*/
|
|
7
|
-
export { slugify }
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*/
|
|
13
|
-
export { generateEntrySlug }
|
|
14
|
-
|
|
1
|
+
import { slugify, generateEntrySlug } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a string into a URL-safe slug.
|
|
5
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
6
|
+
*/
|
|
7
|
+
export { slugify }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generates a slug from a title/name or a UUID-like fallback.
|
|
11
|
+
* Logic moved to @beechcms/core for consistency between Dashboard and API.
|
|
12
|
+
*/
|
|
13
|
+
export { generateEntrySlug }
|
|
14
|
+
|