@beechcms/api 0.4.0-preview.8 → 0.4.0
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-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -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 +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +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 -249
- 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 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- 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 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- 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 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- 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 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
|
@@ -1,183 +1,158 @@
|
|
|
1
|
-
import { isValidContentStatus, resolvePolicies } from '@beechcms/core'
|
|
2
|
-
import type { Seed } from '@beechcms/core'
|
|
3
|
-
import type { Context } from 'hono'
|
|
4
|
-
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
:
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (!Object.hasOwn(body, '
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
if (
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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}'.` })
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const now = Math.floor(Date.now() / 1000)
|
|
165
|
-
const { setClause, bindings } = buildUpdateBindings(seed, dataResult.value)
|
|
166
|
-
const systemSet = `slug = ?, status = ?, updated_at = ?`
|
|
167
|
-
const fullSet = setClause ? `${systemSet}, ${setClause}` : systemSet
|
|
168
|
-
|
|
169
|
-
await DB.prepare(`UPDATE ${table} SET ${fullSet} WHERE id = ?`)
|
|
170
|
-
.bind(slugResult.value.nextSlug, statusResult.value, now, ...bindings, id)
|
|
171
|
-
.run()
|
|
172
|
-
|
|
173
|
-
await createNotification(c, {
|
|
174
|
-
title: `${seed.label}: Modifica`,
|
|
175
|
-
message: `L'entry "${slugResult.value.nextSlug}" è stata modificata via API pubblica.`,
|
|
176
|
-
type: 'info',
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
return c.json({ success: true, id, slug: slugResult.value.nextSlug }, 200)
|
|
180
|
-
} catch (err) {
|
|
181
|
-
return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(c, err) })
|
|
182
|
-
}
|
|
183
|
-
}
|
|
1
|
+
import { isValidContentStatus, resolvePolicies, EntryNotFoundError } from '@beechcms/core'
|
|
2
|
+
import type { Seed } from '@beechcms/core'
|
|
3
|
+
import type { Context } from 'hono'
|
|
4
|
+
import { cleanStr } from '../shared/query-utils'
|
|
5
|
+
import { checkPublicOperation } from './access-policy'
|
|
6
|
+
import { publicProblem } from './problem-details'
|
|
7
|
+
import { slugify } from './slug-utils'
|
|
8
|
+
import { sanitizePublicPayload } from './sanitize'
|
|
9
|
+
import { createNotification } from '../shared/notification-service'
|
|
10
|
+
import { AppEnv } from '../types'
|
|
11
|
+
|
|
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
|
|
13
|
+
type PublicCtx = Context<AppEnv>
|
|
14
|
+
type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
|
|
15
|
+
|
|
16
|
+
function errorMessage(context: PublicCtx, error: unknown): string {
|
|
17
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
18
|
+
return 'An unexpected error occurred.'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
22
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
23
|
+
? (value as Record<string, unknown>)
|
|
24
|
+
: null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function removeNullishFields(data: Record<string, unknown>): Record<string, unknown> {
|
|
28
|
+
const next: Record<string, unknown> = {}
|
|
29
|
+
for (const [key, value] of Object.entries(data)) {
|
|
30
|
+
if (value !== null) next[key] = value
|
|
31
|
+
}
|
|
32
|
+
return next
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseBody(context: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
|
|
36
|
+
return context.req.json<unknown>()
|
|
37
|
+
.then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
|
|
38
|
+
.catch(() => ({
|
|
39
|
+
ok: false,
|
|
40
|
+
response: publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveSlug(context: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
|
|
45
|
+
const slugRequested = Object.hasOwn(body, 'slug')
|
|
46
|
+
if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
|
|
47
|
+
const requestedSlug = cleanStr(body.slug)
|
|
48
|
+
if (!requestedSlug) {
|
|
49
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveStatus(context: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
|
|
55
|
+
if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
|
|
56
|
+
const statusValue = body.status
|
|
57
|
+
if (!isValidContentStatus(statusValue)) {
|
|
58
|
+
return { ok: false, response: publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
|
|
59
|
+
}
|
|
60
|
+
return { ok: true, value: statusValue as string }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resolveData(
|
|
64
|
+
context: PublicCtx,
|
|
65
|
+
seed: Seed,
|
|
66
|
+
body: Record<string, unknown>
|
|
67
|
+
): ResolveResult<Record<string, unknown>> {
|
|
68
|
+
if (!Object.hasOwn(body, 'data')) {
|
|
69
|
+
// No data update — return empty patch
|
|
70
|
+
return { ok: true, value: {} }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rawData = asRecord(body.data)
|
|
74
|
+
if (!rawData) {
|
|
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" }) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sensitiveAliases = Object.keys(rawData).filter((alias) => {
|
|
79
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
80
|
+
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
81
|
+
})
|
|
82
|
+
if (sensitiveAliases.length > 0) {
|
|
83
|
+
return { ok: false, response: publicProblem(context, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
|
|
87
|
+
if (!sanitized.ok) {
|
|
88
|
+
if (sanitized.status === 422) {
|
|
89
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
|
|
90
|
+
}
|
|
91
|
+
return { ok: false, response: publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { ok: true, value: removeNullishFields(sanitized.data) }
|
|
95
|
+
}
|
|
96
|
+
|
|
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)
|
|
101
|
+
if (!seed) {
|
|
102
|
+
return publicProblem(context, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
103
|
+
}
|
|
104
|
+
const access = checkPublicOperation(seed, 'edit')
|
|
105
|
+
if (!access.ok) {
|
|
106
|
+
return publicProblem(context, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!UUID_REGEX.test(id)) {
|
|
110
|
+
return publicProblem(context, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const repository = context.get('repository')
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const entry = await repository.findById(seed, id)
|
|
117
|
+
|
|
118
|
+
const bodyResult = await parseBody(context)
|
|
119
|
+
if (!bodyResult.ok) return bodyResult.response
|
|
120
|
+
|
|
121
|
+
const slugResult = resolveSlug(context, bodyResult.value, (entry.slug as string) ?? '')
|
|
122
|
+
if (!slugResult.ok) return slugResult.response
|
|
123
|
+
|
|
124
|
+
const statusResult = resolveStatus(context, bodyResult.value, (entry.status as string) ?? 'draft')
|
|
125
|
+
if (!statusResult.ok) return statusResult.response
|
|
126
|
+
|
|
127
|
+
const dataResult = resolveData(context, seed, bodyResult.value)
|
|
128
|
+
if (!dataResult.ok) return dataResult.response
|
|
129
|
+
|
|
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}'.` })
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const updateData = { ...dataResult.value }
|
|
138
|
+
if (slugResult.value.slugRequested) {
|
|
139
|
+
(updateData as any).slug = slugResult.value.nextSlug
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await repository.update(seed, id, updateData, statusResult.value)
|
|
143
|
+
|
|
144
|
+
await createNotification(context, {
|
|
145
|
+
title: `${seed.label}: Update`,
|
|
146
|
+
message: `The entry "${slugResult.value.nextSlug}" has been modified via the public API.`,
|
|
147
|
+
type: 'info',
|
|
148
|
+
})
|
|
149
|
+
|
|
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) })
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Error constants for Public API responses.
|
|
3
|
-
*/
|
|
4
|
-
export const PUBLIC_ERRORS = {
|
|
5
|
-
API_KEY_UNAUTHORIZED: {
|
|
6
|
-
error: 'Unauthorized',
|
|
7
|
-
message:
|
|
8
|
-
'Missing or invalid API key. Provide a valid key via X-API-Key header.',
|
|
9
|
-
},
|
|
10
|
-
API_KEY_FORBIDDEN: {
|
|
11
|
-
error: 'Forbidden',
|
|
12
|
-
message: 'Public API access is not configured for this instance.',
|
|
13
|
-
},
|
|
14
|
-
} as const
|
|
15
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Error constants for Public API responses.
|
|
3
|
+
*/
|
|
4
|
+
export const PUBLIC_ERRORS = {
|
|
5
|
+
API_KEY_UNAUTHORIZED: {
|
|
6
|
+
error: 'Unauthorized',
|
|
7
|
+
message:
|
|
8
|
+
'Missing or invalid API key. Provide a valid key via X-API-Key header.',
|
|
9
|
+
},
|
|
10
|
+
API_KEY_FORBIDDEN: {
|
|
11
|
+
error: 'Forbidden',
|
|
12
|
+
message: 'Public API access is not configured for this instance.',
|
|
13
|
+
},
|
|
14
|
+
} as const
|
|
15
|
+
|