@beechcms/api 0.4.0-preview.1 → 0.4.0-preview.11
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 -0
- package/assets/dashboard/BeechLogoLIght.svg +48 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/assets/dashboard/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/assets/dashboard/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/assets/dashboard/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/assets/dashboard/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/assets/dashboard/assets/index-9Ch2xWJr.js +554 -0
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -0
- package/assets/dashboard/index.html +18 -0
- package/assets/dashboard/sol.svg +3 -0
- package/assets/dashboard/undraw_clouds_bmtk.svg +1 -0
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -0
- package/assets/dashboard/vite.svg +1 -0
- package/package.json +4 -3
- package/src/factory.ts +290 -21
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +163 -0
- package/src/features/content/handlers/delete.ts +85 -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 +216 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +203 -129
- package/src/features/password-reset/request.ts +1 -1
- package/src/features/schema/schema.handler.ts +16 -0
- package/src/features/settings/settings.handler.ts +18 -0
- package/src/index.ts +17 -337
- package/src/middleware/repository.middleware.ts +18 -0
- package/src/public/public-add.ts +72 -89
- package/src/public/public-edit.ts +51 -76
- package/src/public/public-read.ts +113 -114
- package/src/public/query-builder.ts +47 -136
- package/src/search-utils.ts +5 -1
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/idempotency.repository.d1.ts +45 -0
- package/src/types.ts +7 -1
- package/src/upload.ts +3 -7
- 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
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
import { isValidContentStatus, resolvePolicies } from '@beechcms/core'
|
|
1
|
+
import { isValidContentStatus, resolvePolicies, EntryNotFoundError } from '@beechcms/core'
|
|
2
2
|
import type { Seed } from '@beechcms/core'
|
|
3
3
|
import type { Context } from 'hono'
|
|
4
4
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { rowToApiData, buildUpdateBindings } from '../shared/content-utils'
|
|
6
5
|
import { checkPublicOperation } from './access-policy'
|
|
7
6
|
import { publicProblem } from './problem-details'
|
|
8
7
|
import { slugify } from './slug-utils'
|
|
9
8
|
import { sanitizePublicPayload } from './sanitize'
|
|
10
9
|
import { createNotification } from '../shared/notification-service'
|
|
11
|
-
|
|
12
|
-
type Bindings = {
|
|
13
|
-
DB: D1Database
|
|
14
|
-
PUBLIC_READ_API_KEY?: string
|
|
15
|
-
PUBLIC_WRITE_API_KEY?: string
|
|
16
|
-
ENV?: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type Variables = {
|
|
20
|
-
jwtPayload: { sub: string; email?: string }
|
|
21
|
-
getSeed: (slug: string) => Seed | null
|
|
22
|
-
seedRegistry: Record<string, Seed>
|
|
23
|
-
}
|
|
10
|
+
import { AppEnv } from '../types'
|
|
24
11
|
|
|
25
12
|
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
26
|
-
type PublicCtx = Context<
|
|
13
|
+
type PublicCtx = Context<AppEnv>
|
|
27
14
|
type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
|
|
28
15
|
|
|
29
|
-
function errorMessage(
|
|
30
|
-
if (
|
|
16
|
+
function errorMessage(context: PublicCtx, error: unknown): string {
|
|
17
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
31
18
|
return 'An unexpected error occurred.'
|
|
32
19
|
}
|
|
33
20
|
|
|
@@ -45,48 +32,47 @@ function removeNullishFields(data: Record<string, unknown>): Record<string, unkn
|
|
|
45
32
|
return next
|
|
46
33
|
}
|
|
47
34
|
|
|
48
|
-
function parseBody(
|
|
49
|
-
return
|
|
35
|
+
function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
|
|
36
|
+
return context.req.json<unknown>()
|
|
50
37
|
.then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
|
|
51
38
|
.catch(() => ({
|
|
52
39
|
ok: false,
|
|
53
|
-
response: publicProblem(
|
|
40
|
+
response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
|
|
54
41
|
}))
|
|
55
42
|
}
|
|
56
43
|
|
|
57
|
-
function resolveSlug(
|
|
44
|
+
function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
|
|
58
45
|
const slugRequested = Object.hasOwn(body, 'slug')
|
|
59
46
|
if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
|
|
60
47
|
const requestedSlug = cleanStr(body.slug)
|
|
61
48
|
if (!requestedSlug) {
|
|
62
|
-
return { ok: false, response: publicProblem(
|
|
49
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
|
|
63
50
|
}
|
|
64
51
|
return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
|
|
65
52
|
}
|
|
66
53
|
|
|
67
|
-
function resolveStatus(
|
|
54
|
+
function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
|
|
68
55
|
if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
|
|
69
56
|
const statusValue = body.status
|
|
70
57
|
if (!isValidContentStatus(statusValue)) {
|
|
71
|
-
return { ok: false, response: publicProblem(
|
|
58
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
|
|
72
59
|
}
|
|
73
|
-
return { ok: true, value: statusValue }
|
|
60
|
+
return { ok: true, value: statusValue as string }
|
|
74
61
|
}
|
|
75
62
|
|
|
76
63
|
function resolveData(
|
|
77
|
-
|
|
64
|
+
context: PublicCtx,
|
|
78
65
|
seed: Seed,
|
|
79
|
-
body: Record<string, unknown
|
|
80
|
-
currentRow: Record<string, unknown>
|
|
66
|
+
body: Record<string, unknown>
|
|
81
67
|
): ResolveResult<Record<string, unknown>> {
|
|
82
68
|
if (!Object.hasOwn(body, 'data')) {
|
|
83
|
-
// No data update — return empty patch
|
|
69
|
+
// No data update — return empty patch
|
|
84
70
|
return { ok: true, value: {} }
|
|
85
71
|
}
|
|
86
72
|
|
|
87
73
|
const rawData = asRecord(body.data)
|
|
88
74
|
if (!rawData) {
|
|
89
|
-
return { ok: false, response: publicProblem(
|
|
75
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
|
|
90
76
|
}
|
|
91
77
|
|
|
92
78
|
const sensitiveAliases = Object.keys(rawData).filter((alias) => {
|
|
@@ -94,90 +80,79 @@ function resolveData(
|
|
|
94
80
|
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
95
81
|
})
|
|
96
82
|
if (sensitiveAliases.length > 0) {
|
|
97
|
-
return { ok: false, response: publicProblem(
|
|
83
|
+
return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
|
|
98
84
|
}
|
|
99
85
|
|
|
100
86
|
const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
|
|
101
87
|
if (!sanitized.ok) {
|
|
102
88
|
if (sanitized.status === 422) {
|
|
103
|
-
return { ok: false, response: publicProblem(
|
|
89
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
|
|
104
90
|
}
|
|
105
|
-
return { ok: false, response: publicProblem(
|
|
91
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
|
|
106
92
|
}
|
|
107
93
|
|
|
108
|
-
|
|
109
|
-
const currentAliasData = rowToApiData(seed, currentRow)
|
|
110
|
-
const merged = removeNullishFields({ ...currentAliasData, ...sanitized.data })
|
|
111
|
-
return { ok: true, value: merged }
|
|
94
|
+
return { ok: true, value: removeNullishFields(sanitized.data) }
|
|
112
95
|
}
|
|
113
96
|
|
|
114
|
-
export async function publicEditHandler(
|
|
115
|
-
const seedSlug =
|
|
116
|
-
const id =
|
|
117
|
-
const seed =
|
|
97
|
+
export async function publicEditHandler(context: PublicCtx) {
|
|
98
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
99
|
+
const id = context.req.param('id') ?? ''
|
|
100
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
118
101
|
if (!seed) {
|
|
119
|
-
return publicProblem(
|
|
102
|
+
return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
120
103
|
}
|
|
121
104
|
const access = checkPublicOperation(seed, 'edit')
|
|
122
105
|
if (!access.ok) {
|
|
123
|
-
return publicProblem(
|
|
106
|
+
return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
124
107
|
}
|
|
125
108
|
|
|
126
109
|
if (!UUID_REGEX.test(id)) {
|
|
127
|
-
return publicProblem(
|
|
110
|
+
return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
|
|
128
111
|
}
|
|
129
112
|
|
|
130
|
-
const
|
|
113
|
+
const repository = context.get('repository')
|
|
131
114
|
|
|
132
115
|
try {
|
|
133
|
-
const
|
|
134
|
-
const currentRow = await DB.prepare(`SELECT * FROM ${table} WHERE id = ? LIMIT 1`)
|
|
135
|
-
.bind(id)
|
|
136
|
-
.first<Record<string, unknown>>()
|
|
137
|
-
|
|
138
|
-
if (!currentRow) {
|
|
139
|
-
return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
|
|
140
|
-
}
|
|
116
|
+
const entry = await repository.findById(seed, id)
|
|
141
117
|
|
|
142
|
-
const bodyResult = await parseBody(
|
|
118
|
+
const bodyResult = await parseBody(context)
|
|
143
119
|
if (!bodyResult.ok) return bodyResult.response
|
|
144
120
|
|
|
145
|
-
const slugResult = resolveSlug(
|
|
121
|
+
const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
|
|
146
122
|
if (!slugResult.ok) return slugResult.response
|
|
147
123
|
|
|
148
|
-
const statusResult = resolveStatus(
|
|
124
|
+
const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
|
|
149
125
|
if (!statusResult.ok) return statusResult.response
|
|
150
126
|
|
|
151
|
-
const dataResult = resolveData(
|
|
127
|
+
const dataResult = resolveData(context, seed, bodyResult.value)
|
|
152
128
|
if (!dataResult.ok) return dataResult.response
|
|
153
129
|
|
|
154
|
-
if (slugResult.value.slugRequested) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (slugExisting) {
|
|
160
|
-
return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
|
|
130
|
+
if (slugResult.value.slugRequested && slugResult.value.nextSlug !== entry.slug) {
|
|
131
|
+
const exists = await repository.existsSlug(seed, slugResult.value.nextSlug, id)
|
|
132
|
+
if (exists) {
|
|
133
|
+
return publicProblem(context, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${slugResult.value.nextSlug}' already exists for content type '${seedSlug}'.` })
|
|
161
134
|
}
|
|
162
135
|
}
|
|
163
136
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
137
|
+
const updateData = { ...dataResult.value }
|
|
138
|
+
if (slugResult.value.slugRequested) {
|
|
139
|
+
(updateData as any).slug = slugResult.value.nextSlug
|
|
140
|
+
}
|
|
168
141
|
|
|
169
|
-
await
|
|
170
|
-
.bind(slugResult.value.nextSlug, statusResult.value, now, ...bindings, id)
|
|
171
|
-
.run()
|
|
142
|
+
await repository.update(seed, id, updateData, statusResult.value)
|
|
172
143
|
|
|
173
|
-
await createNotification(
|
|
144
|
+
await createNotification(context, {
|
|
174
145
|
title: `${seed.label}: Modifica`,
|
|
175
146
|
message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
|
|
176
147
|
type: 'info',
|
|
177
148
|
})
|
|
178
149
|
|
|
179
|
-
return
|
|
180
|
-
} catch (
|
|
181
|
-
|
|
150
|
+
return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof EntryNotFoundError) {
|
|
153
|
+
return publicProblem(context, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
|
|
154
|
+
}
|
|
155
|
+
console.error('Public edit error:', error)
|
|
156
|
+
return publicProblem(context, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(context, error) })
|
|
182
157
|
}
|
|
183
158
|
}
|
|
@@ -1,31 +1,17 @@
|
|
|
1
|
-
import { resolvePolicies } from '@beechcms/core'
|
|
1
|
+
import { resolvePolicies, EntryNotFoundError } from '@beechcms/core'
|
|
2
2
|
import type { Seed } from '@beechcms/core'
|
|
3
3
|
import type { Context } from 'hono'
|
|
4
4
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { rowToApiData } from '../shared/content-utils'
|
|
6
5
|
import { checkPublicOperation } from './access-policy'
|
|
7
6
|
import { publicProblem } from './problem-details'
|
|
8
7
|
import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
|
|
9
8
|
import {
|
|
10
|
-
buildPublicFilterWhereClause,
|
|
11
9
|
parseLatestCount,
|
|
12
10
|
parsePublicFilter,
|
|
13
11
|
parsePublicPagination,
|
|
12
|
+
toEngineFilters,
|
|
14
13
|
} from './query-builder'
|
|
15
|
-
|
|
16
|
-
type Bindings = {
|
|
17
|
-
DB: D1Database
|
|
18
|
-
PUBLIC_READ_API_KEY?: string
|
|
19
|
-
PUBLIC_WRITE_API_KEY?: string
|
|
20
|
-
PUBLIC_PUBLISHED_ONLY?: string
|
|
21
|
-
ENV?: string
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type Variables = {
|
|
25
|
-
jwtPayload: { sub: string; email?: string }
|
|
26
|
-
getSeed: (slug: string) => Seed | null
|
|
27
|
-
seedRegistry: Record<string, Seed>
|
|
28
|
-
}
|
|
14
|
+
import { AppEnv } from '../types'
|
|
29
15
|
|
|
30
16
|
function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
|
|
31
17
|
const available = Object.keys(seedRegistry).join(', ')
|
|
@@ -33,10 +19,17 @@ function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, See
|
|
|
33
19
|
}
|
|
34
20
|
|
|
35
21
|
/** Applica policy public/visibility per la Public API. */
|
|
36
|
-
function applyPublicPolicies(
|
|
22
|
+
function applyPublicPolicies(data: Record<string, unknown>, seed: Seed): Record<string, unknown> {
|
|
37
23
|
const result: Record<string, unknown> = {}
|
|
24
|
+
|
|
25
|
+
// System fields are mapped to top-level for public API
|
|
26
|
+
const system = ['id', 'slug', 'status', 'created_at', 'updated_at']
|
|
27
|
+
for (const key of system) {
|
|
28
|
+
if (key in data) result[key] = data[key]
|
|
29
|
+
}
|
|
30
|
+
|
|
38
31
|
for (const branch of seed.branches) {
|
|
39
|
-
const value =
|
|
32
|
+
const value = data[branch.alias]
|
|
40
33
|
const { public: isPublic, visibility } = resolvePolicies(branch)
|
|
41
34
|
if (!isPublic) continue
|
|
42
35
|
if (visibility === 'hidden') continue
|
|
@@ -49,22 +42,24 @@ function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Re
|
|
|
49
42
|
return result
|
|
50
43
|
}
|
|
51
44
|
|
|
52
|
-
function toFlatPublicEntry(
|
|
53
|
-
const aliasData = applyPublicPolicies(
|
|
54
|
-
const base: Record<string, unknown> = {
|
|
55
|
-
id: row.id,
|
|
56
|
-
slug: row.slug,
|
|
57
|
-
status: row.status,
|
|
58
|
-
created_at: row.created_at,
|
|
59
|
-
updated_at: row.updated_at,
|
|
60
|
-
}
|
|
45
|
+
function toFlatPublicEntry(data: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
|
|
46
|
+
const aliasData = applyPublicPolicies(data, seed)
|
|
61
47
|
const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
62
|
-
|
|
48
|
+
|
|
49
|
+
if (requestedFields.length === 0) return aliasData
|
|
50
|
+
|
|
63
51
|
const filteredData: Record<string, unknown> = {}
|
|
64
52
|
for (const field of requestedFields) {
|
|
65
53
|
if (field in aliasData) filteredData[field] = aliasData[field]
|
|
66
54
|
}
|
|
67
|
-
|
|
55
|
+
|
|
56
|
+
// Always include basic identity fields if they exist in filtered data or if requested
|
|
57
|
+
const identity = ['id', 'slug']
|
|
58
|
+
for (const key of identity) {
|
|
59
|
+
if (key in aliasData && !filteredData[key]) filteredData[key] = aliasData[key]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return filteredData
|
|
68
63
|
}
|
|
69
64
|
|
|
70
65
|
function withCache(
|
|
@@ -82,74 +77,85 @@ function withCache(
|
|
|
82
77
|
return response
|
|
83
78
|
}
|
|
84
79
|
|
|
85
|
-
function buildInternalErrorMessage(
|
|
86
|
-
if (
|
|
80
|
+
function buildInternalErrorMessage(context: Context<AppEnv>, error: unknown): string {
|
|
81
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
87
82
|
return 'An unexpected error occurred.'
|
|
88
83
|
}
|
|
89
84
|
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const orderDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase()
|
|
94
|
-
if (orderBy === 'created_at' || orderBy === 'updated_at') {
|
|
95
|
-
return `ORDER BY ${orderBy} ${orderDir === 'asc' ? 'ASC' : 'DESC'}`
|
|
96
|
-
}
|
|
97
|
-
// Branch column — always a real column in v0.4.0
|
|
98
|
-
const branch = seed.branches.find((b) => b.alias === orderBy)
|
|
99
|
-
if (branch) {
|
|
100
|
-
const dir = orderDir === 'asc' ? 'ASC' : 'DESC'
|
|
101
|
-
return `ORDER BY ${branch.alias} ${dir} NULLS LAST`
|
|
102
|
-
}
|
|
103
|
-
return 'ORDER BY created_at DESC'
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
|
|
107
|
-
const seedSlug = c.req.param('seed') ?? ''
|
|
108
|
-
const seed = c.get('getSeed')(seedSlug)
|
|
85
|
+
export async function publicReadHandler(context: Context<AppEnv>) {
|
|
86
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
87
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
109
88
|
if (!seed) {
|
|
110
|
-
return publicProblem(
|
|
111
|
-
type: 'seed-not-found',
|
|
112
|
-
|
|
89
|
+
return publicProblem(context, {
|
|
90
|
+
type: 'seed-not-found',
|
|
91
|
+
title: 'Seed Not Found',
|
|
92
|
+
status: 404,
|
|
93
|
+
detail: buildSeedNotFoundMessage(seedSlug, context.get('seedRegistry')),
|
|
113
94
|
})
|
|
114
95
|
}
|
|
96
|
+
|
|
115
97
|
const access = checkPublicOperation(seed, 'read')
|
|
116
98
|
if (!access.ok) {
|
|
117
|
-
return publicProblem(
|
|
99
|
+
return publicProblem(context, {
|
|
100
|
+
type: 'operation-not-allowed',
|
|
101
|
+
title: access.error.error,
|
|
102
|
+
status: 403,
|
|
103
|
+
detail: access.error.message
|
|
104
|
+
})
|
|
118
105
|
}
|
|
119
106
|
|
|
120
107
|
let cache: Cache | undefined
|
|
121
108
|
let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
|
|
122
109
|
try {
|
|
123
110
|
cache = caches.default
|
|
124
|
-
executionCtx =
|
|
111
|
+
executionCtx = context.executionCtx as typeof executionCtx
|
|
125
112
|
} catch {}
|
|
126
113
|
|
|
127
|
-
const cacheKey =
|
|
114
|
+
const cacheKey = context.req.raw
|
|
128
115
|
if (cache) {
|
|
129
116
|
const hit = await cache.match(cacheKey)
|
|
130
117
|
if (hit) return hit
|
|
131
118
|
}
|
|
132
119
|
|
|
133
|
-
const query =
|
|
120
|
+
const query = context.req.query()
|
|
134
121
|
const id = cleanStr(query.id)
|
|
135
|
-
const
|
|
136
|
-
const publishedOnly =
|
|
122
|
+
const slug = cleanStr(query.slug)
|
|
123
|
+
const publishedOnly = context.env.PUBLIC_PUBLISHED_ONLY !== 'false'
|
|
124
|
+
const repository = context.get('repository')
|
|
137
125
|
|
|
138
126
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
127
|
+
if (id || slug) {
|
|
128
|
+
try {
|
|
129
|
+
const entry = id
|
|
130
|
+
? await repository.findById(seed, id)
|
|
131
|
+
: await repository.findBySlug(seed, slug!)
|
|
132
|
+
|
|
133
|
+
if (publishedOnly && entry.status !== 'published') {
|
|
134
|
+
return publicProblem(context, {
|
|
135
|
+
type: 'entry-not-found',
|
|
136
|
+
title: 'Not Found',
|
|
137
|
+
status: 404,
|
|
138
|
+
detail: `Entry '${id || slug}' not found or not published.`
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return withCache(cache, executionCtx, cacheKey,
|
|
143
|
+
context.json({
|
|
144
|
+
data: toFlatPublicEntry(entry, seed, query.fields),
|
|
145
|
+
meta: buildPublicSingleMeta(seedSlug)
|
|
146
|
+
}, 200)
|
|
147
|
+
)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error instanceof EntryNotFoundError) {
|
|
150
|
+
return publicProblem(context, {
|
|
151
|
+
type: 'entry-not-found',
|
|
152
|
+
title: 'Not Found',
|
|
153
|
+
status: 404,
|
|
154
|
+
detail: `Entry '${id || slug}' not found for content type '${seedSlug}'.`
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
throw error
|
|
148
158
|
}
|
|
149
|
-
|
|
150
|
-
return withCache(cache, executionCtx, cacheKey,
|
|
151
|
-
c.json({ data: toFlatPublicEntry(row, seed, query.fields), meta: buildPublicSingleMeta(seedSlug) }, 200)
|
|
152
|
-
)
|
|
153
159
|
}
|
|
154
160
|
|
|
155
161
|
const parsedFilter = parsePublicFilter(query.filter)
|
|
@@ -160,58 +166,51 @@ export async function publicReadHandler(c: Context<{ Bindings: Bindings; Variabl
|
|
|
160
166
|
const offset = (pagination.page - 1) * pagination.limit
|
|
161
167
|
const search = cleanStr(query.search) ?? ''
|
|
162
168
|
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (filterClause.clause) {
|
|
178
|
-
whereParts.push(`(${filterClause.clause})`)
|
|
179
|
-
whereBindings.push(...filterClause.bindings)
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : ''
|
|
183
|
-
const countRow = await DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
|
|
184
|
-
.bind(...whereBindings)
|
|
185
|
-
.first<{ total: number }>()
|
|
186
|
-
const total = countRow?.total ?? 0
|
|
187
|
-
|
|
188
|
-
const orderSql = buildOrderSql(seed, query, latestMode)
|
|
189
|
-
const effectiveLimit = latestMode ? (latestCount ?? 10) : pagination.limit
|
|
190
|
-
const effectiveOffset = latestMode ? 0 : offset
|
|
191
|
-
|
|
192
|
-
const rowsResult = await DB.prepare(`SELECT * FROM ${table} ${whereSql} ${orderSql} LIMIT ? OFFSET ?`)
|
|
193
|
-
.bind(...whereBindings, effectiveLimit, effectiveOffset)
|
|
194
|
-
.all<Record<string, unknown>>()
|
|
169
|
+
const engineFilters = toEngineFilters(seed, parsedFilter)
|
|
170
|
+
const sortBy = cleanStr(query.orderBy) ?? 'created_at'
|
|
171
|
+
const sortDir = (cleanStr(query.orderDir) ?? 'desc').toLowerCase() === 'asc' ? 'ASC' : 'DESC'
|
|
172
|
+
|
|
173
|
+
const { items, total } = await repository.findMany(seed, {
|
|
174
|
+
filters: engineFilters,
|
|
175
|
+
search: search || undefined,
|
|
176
|
+
status: publishedOnly ? 'published' : null,
|
|
177
|
+
pagination: {
|
|
178
|
+
limit: latestMode ? (latestCount ?? 10) : pagination.limit,
|
|
179
|
+
offset: latestMode ? 0 : offset
|
|
180
|
+
},
|
|
181
|
+
orderBy: latestMode ? { column: 'created_at', dir: 'DESC' } : { column: sortBy, dir: sortDir }
|
|
182
|
+
})
|
|
195
183
|
|
|
196
|
-
const
|
|
197
|
-
const data = rows.map((row) => toFlatPublicEntry(row, seed, query.fields))
|
|
184
|
+
const data = items.map((item) => toFlatPublicEntry(item, seed, query.fields))
|
|
198
185
|
|
|
199
186
|
if (latestMode) {
|
|
200
187
|
return withCache(cache, executionCtx, cacheKey,
|
|
201
|
-
|
|
188
|
+
context.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
|
|
202
189
|
)
|
|
203
190
|
}
|
|
204
191
|
|
|
205
192
|
return withCache(cache, executionCtx, cacheKey,
|
|
206
|
-
|
|
193
|
+
context.json({
|
|
207
194
|
data,
|
|
208
|
-
meta: buildPublicListMeta({
|
|
195
|
+
meta: buildPublicListMeta({
|
|
196
|
+
total,
|
|
197
|
+
page: pagination.page,
|
|
198
|
+
limit: latestMode ? (latestCount ?? 10) : pagination.limit,
|
|
199
|
+
returned: data.length,
|
|
200
|
+
seed: seedSlug
|
|
201
|
+
}),
|
|
209
202
|
}, 200)
|
|
210
203
|
)
|
|
211
|
-
} catch (
|
|
212
|
-
if (
|
|
213
|
-
return publicProblem(
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (error instanceof Error && error.message.startsWith('Invalid filter:')) {
|
|
206
|
+
return publicProblem(context, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: error.message })
|
|
214
207
|
}
|
|
215
|
-
|
|
208
|
+
console.error('Public read error:', error)
|
|
209
|
+
return publicProblem(context, {
|
|
210
|
+
type: 'internal-server-error',
|
|
211
|
+
title: 'Internal Server Error',
|
|
212
|
+
status: 500,
|
|
213
|
+
detail: buildInternalErrorMessage(context, error)
|
|
214
|
+
})
|
|
216
215
|
}
|
|
217
216
|
}
|