@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,183 @@
|
|
|
1
|
+
import { isValidContentStatus } 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 { buildInsertBindings } from '../shared/content-utils'
|
|
6
|
+
import { checkPublicOperation } from './access-policy'
|
|
7
|
+
import { publicProblem } from './problem-details'
|
|
8
|
+
import { generateEntrySlug, slugify } from './slug-utils'
|
|
9
|
+
import { sanitizePublicPayload } from './sanitize'
|
|
10
|
+
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
|
+
PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
|
|
17
|
+
ENV?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Variables = {
|
|
21
|
+
jwtPayload: { sub: string; email?: string }
|
|
22
|
+
getSeed: (slug: string) => Seed | null
|
|
23
|
+
seedRegistry: Record<string, Seed>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function errorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
|
|
27
|
+
if (c.env.ENV !== 'production' && err instanceof Error) return err.message
|
|
28
|
+
return 'An unexpected error occurred.'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
? (value as Record<string, unknown>)
|
|
34
|
+
: null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function pickSlugFromBody(body: Record<string, unknown>, sanitizedData: Record<string, unknown>): string {
|
|
38
|
+
const explicitSlug = cleanStr(body.slug)
|
|
39
|
+
if (explicitSlug) return slugify(explicitSlug)
|
|
40
|
+
return generateEntrySlug({ title: sanitizedData.title, name: sanitizedData.name })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseIdempotencyKey(rawValue: string | undefined): string | null {
|
|
44
|
+
if (!rawValue) return null
|
|
45
|
+
const key = rawValue.trim()
|
|
46
|
+
if (!key || key.length > 128) return null
|
|
47
|
+
return key
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toHex(buffer: ArrayBuffer): string {
|
|
51
|
+
return [...new Uint8Array(buffer)].map((v) => v.toString(16).padStart(2, '0')).join('')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function sha256Hex(input: string): Promise<string> {
|
|
55
|
+
const data = new TextEncoder().encode(input)
|
|
56
|
+
const digest = await crypto.subtle.digest('SHA-256', data)
|
|
57
|
+
return toHex(digest)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type StoredIdempotencyRecord = {
|
|
61
|
+
idempotency_key: string
|
|
62
|
+
request_fingerprint: string
|
|
63
|
+
response_status: number
|
|
64
|
+
response_body: string
|
|
65
|
+
expires_at: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function lookupIdempotency(db: D1Database, key: string): Promise<StoredIdempotencyRecord | null> {
|
|
69
|
+
return db.prepare(
|
|
70
|
+
`SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
|
|
71
|
+
FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
|
|
72
|
+
).bind(key).first<StoredIdempotencyRecord>()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function storeIdempotency(db: D1Database, input: {
|
|
76
|
+
key: string; fingerprint: string; responseStatus: number
|
|
77
|
+
responseBody: string; expiresAt: number; createdAt: number
|
|
78
|
+
}): Promise<void> {
|
|
79
|
+
await db.prepare(
|
|
80
|
+
`INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
|
|
81
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
82
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
83
|
+
request_fingerprint = excluded.request_fingerprint,
|
|
84
|
+
response_status = excluded.response_status,
|
|
85
|
+
response_body = excluded.response_body,
|
|
86
|
+
created_at = excluded.created_at,
|
|
87
|
+
expires_at = excluded.expires_at`
|
|
88
|
+
).bind(input.key, input.fingerprint, input.responseStatus, input.responseBody, input.createdAt, input.expiresAt).run()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function publicAddHandler(c: Context<{ Bindings: Bindings; Variables: Variables }>) {
|
|
92
|
+
const seedSlug = c.req.param('seed') ?? ''
|
|
93
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
94
|
+
if (!seed) {
|
|
95
|
+
return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
96
|
+
}
|
|
97
|
+
const access = checkPublicOperation(seed, 'add')
|
|
98
|
+
if (!access.ok) {
|
|
99
|
+
return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let body: Record<string, unknown>
|
|
103
|
+
try {
|
|
104
|
+
const parsed = await c.req.json<unknown>()
|
|
105
|
+
body = asRecord(parsed) ?? {}
|
|
106
|
+
} catch {
|
|
107
|
+
return publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const rawData = asRecord(body.data)
|
|
111
|
+
if (!rawData || Object.keys(rawData).length === 0) {
|
|
112
|
+
return publicProblem(c, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const statusValue = body.status ?? 'draft'
|
|
116
|
+
if (!isValidContentStatus(statusValue)) {
|
|
117
|
+
return publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sanitized = sanitizePublicPayload(seed, rawData, {
|
|
121
|
+
operation: 'create', allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: true,
|
|
122
|
+
})
|
|
123
|
+
if (!sanitized.ok) {
|
|
124
|
+
if (sanitized.status === 422) {
|
|
125
|
+
return publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
|
|
126
|
+
}
|
|
127
|
+
return publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const idempotencyKey = parseIdempotencyKey(c.req.header('Idempotency-Key'))
|
|
131
|
+
const entrySlug = pickSlugFromBody(body, sanitized.data)
|
|
132
|
+
const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
|
|
133
|
+
const table = `content_${seedSlug}`
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const { DB } = c.env
|
|
137
|
+
const now = Math.floor(Date.now() / 1000)
|
|
138
|
+
const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
|
|
139
|
+
const fingerprint = await sha256Hex(fingerprintPayload)
|
|
140
|
+
const idempotencyTtlSeconds = Math.max(60, Number.parseInt(c.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
|
|
141
|
+
|
|
142
|
+
if (idempotencyKey) {
|
|
143
|
+
const existing = await lookupIdempotency(DB, idempotencyKey)
|
|
144
|
+
if (existing && existing.expires_at >= now) {
|
|
145
|
+
if (existing.request_fingerprint !== fingerprint) {
|
|
146
|
+
return publicProblem(c, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
|
|
147
|
+
}
|
|
148
|
+
let parsedBody: unknown = null
|
|
149
|
+
try { parsedBody = JSON.parse(existing.response_body) } catch { parsedBody = { success: true } }
|
|
150
|
+
return c.json(parsedBody, existing.response_status as 201)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? LIMIT 1`).bind(finalSlug).first<{ id: string }>()
|
|
155
|
+
if (slugExisting) {
|
|
156
|
+
return publicProblem(c, { type: 'slug-conflict', title: 'Conflict', status: 409, detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.` })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const id = crypto.randomUUID()
|
|
160
|
+
const { cols, placeholders, bindings } = buildInsertBindings(seed, sanitized.data)
|
|
161
|
+
const systemCols = ['id', 'slug', 'status', 'created_at', 'updated_at']
|
|
162
|
+
const systemVals = [id, finalSlug, statusValue, now, now]
|
|
163
|
+
const allCols = [...systemCols, ...cols].join(', ')
|
|
164
|
+
const allPlaceholders = [...systemVals.map(() => '?'), ...placeholders].join(', ')
|
|
165
|
+
|
|
166
|
+
await DB.prepare(`INSERT INTO ${table} (${allCols}) VALUES (${allPlaceholders})`).bind(...systemVals, ...bindings).run()
|
|
167
|
+
|
|
168
|
+
const responseBody = { success: true, id, slug: finalSlug }
|
|
169
|
+
if (idempotencyKey) {
|
|
170
|
+
await storeIdempotency(DB, { key: idempotencyKey, fingerprint, responseStatus: 201, responseBody: JSON.stringify(responseBody), createdAt: now, expiresAt: now + idempotencyTtlSeconds })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await createNotification(c, {
|
|
174
|
+
title: `${seed.label}: Nuovo inserimento`,
|
|
175
|
+
message: `Una nuova entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") è stata aggiunta via API pubblica.`,
|
|
176
|
+
type: 'success',
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
return c.json(responseBody, 201)
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: errorMessage(c, err) })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
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 { rowToApiData, buildUpdateBindings } from '../shared/content-utils'
|
|
6
|
+
import { checkPublicOperation } from './access-policy'
|
|
7
|
+
import { publicProblem } from './problem-details'
|
|
8
|
+
import { slugify } from './slug-utils'
|
|
9
|
+
import { sanitizePublicPayload } from './sanitize'
|
|
10
|
+
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
|
+
}
|
|
24
|
+
|
|
25
|
+
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<{ Bindings: Bindings; Variables: Variables }>
|
|
27
|
+
type ResolveResult<T> = { ok: true; value: T } | { ok: false; response: Response }
|
|
28
|
+
|
|
29
|
+
function errorMessage(c: PublicCtx, err: unknown): string {
|
|
30
|
+
if (c.env.ENV !== 'production' && err instanceof Error) return err.message
|
|
31
|
+
return 'An unexpected error occurred.'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
35
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
36
|
+
? (value as Record<string, unknown>)
|
|
37
|
+
: null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function removeNullishFields(data: Record<string, unknown>): Record<string, unknown> {
|
|
41
|
+
const next: Record<string, unknown> = {}
|
|
42
|
+
for (const [key, value] of Object.entries(data)) {
|
|
43
|
+
if (value !== null) next[key] = value
|
|
44
|
+
}
|
|
45
|
+
return next
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseBody(c: PublicCtx): Promise<ResolveResult<Record<string, unknown>>> {
|
|
49
|
+
return c.req.json<unknown>()
|
|
50
|
+
.then((parsed) => ({ ok: true, value: asRecord(parsed) ?? {} }) as const)
|
|
51
|
+
.catch(() => ({
|
|
52
|
+
ok: false,
|
|
53
|
+
response: publicProblem(c, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' }),
|
|
54
|
+
}))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveSlug(c: PublicCtx, body: Record<string, unknown>, currentSlug: string): ResolveResult<{ slugRequested: boolean; nextSlug: string }> {
|
|
58
|
+
const slugRequested = Object.hasOwn(body, 'slug')
|
|
59
|
+
if (!slugRequested) return { ok: true, value: { slugRequested, nextSlug: currentSlug } }
|
|
60
|
+
const requestedSlug = cleanStr(body.slug)
|
|
61
|
+
if (!requestedSlug) {
|
|
62
|
+
return { ok: false, response: publicProblem(c, { type: 'invalid-slug', title: 'Bad Request', status: 400, detail: "Field 'slug' must be a non-empty string" }) }
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, value: { slugRequested, nextSlug: slugify(requestedSlug) } }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function resolveStatus(c: PublicCtx, body: Record<string, unknown>, currentStatus: string): ResolveResult<string> {
|
|
68
|
+
if (!Object.hasOwn(body, 'status')) return { ok: true, value: currentStatus }
|
|
69
|
+
const statusValue = body.status
|
|
70
|
+
if (!isValidContentStatus(statusValue)) {
|
|
71
|
+
return { ok: false, response: publicProblem(c, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' }) }
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, value: statusValue }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function resolveData(
|
|
77
|
+
c: PublicCtx,
|
|
78
|
+
seed: Seed,
|
|
79
|
+
body: Record<string, unknown>,
|
|
80
|
+
currentRow: Record<string, unknown>
|
|
81
|
+
): ResolveResult<Record<string, unknown>> {
|
|
82
|
+
if (!Object.hasOwn(body, 'data')) {
|
|
83
|
+
// No data update — return empty patch (caller keeps existing columns)
|
|
84
|
+
return { ok: true, value: {} }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawData = asRecord(body.data)
|
|
88
|
+
if (!rawData) {
|
|
89
|
+
return { ok: false, response: publicProblem(c, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' must be an object when provided" }) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sensitiveAliases = Object.keys(rawData).filter((alias) => {
|
|
93
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
94
|
+
return branch != null && resolvePolicies(branch).privacy !== 'plain'
|
|
95
|
+
})
|
|
96
|
+
if (sensitiveAliases.length > 0) {
|
|
97
|
+
return { ok: false, response: publicProblem(c, { type: 'sensitive-field-edit', title: 'Unprocessable Entity', status: 422, detail: `Cannot edit sensitive fields: ${sensitiveAliases.join(', ')}` }) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sanitized = sanitizePublicPayload(seed, rawData, { allowNull: true, operation: 'update', requireAtLeastOneValidField: true, enforceRequiredFields: true })
|
|
101
|
+
if (!sanitized.ok) {
|
|
102
|
+
if (sanitized.status === 422) {
|
|
103
|
+
return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message }) }
|
|
104
|
+
}
|
|
105
|
+
return { ok: false, response: publicProblem(c, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details }) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Merge: current columns + patch, remove nulls
|
|
109
|
+
const currentAliasData = rowToApiData(seed, currentRow)
|
|
110
|
+
const merged = removeNullishFields({ ...currentAliasData, ...sanitized.data })
|
|
111
|
+
return { ok: true, value: merged }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function publicEditHandler(c: PublicCtx) {
|
|
115
|
+
const seedSlug = c.req.param('seed') ?? ''
|
|
116
|
+
const id = c.req.param('id') ?? ''
|
|
117
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
118
|
+
if (!seed) {
|
|
119
|
+
return publicProblem(c, { type: 'seed-not-found', title: 'Seed Not Found', status: 404, detail: `The content type '${seedSlug}' does not exist.` })
|
|
120
|
+
}
|
|
121
|
+
const access = checkPublicOperation(seed, 'edit')
|
|
122
|
+
if (!access.ok) {
|
|
123
|
+
return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!UUID_REGEX.test(id)) {
|
|
127
|
+
return publicProblem(c, { type: 'invalid-entry-id', title: 'Bad Request', status: 400, detail: 'Invalid entry ID format' })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const table = `content_${seedSlug}`
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const { DB } = c.env
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
const bodyResult = await parseBody(c)
|
|
143
|
+
if (!bodyResult.ok) return bodyResult.response
|
|
144
|
+
|
|
145
|
+
const slugResult = resolveSlug(c, bodyResult.value, (currentRow.slug as string) ?? '')
|
|
146
|
+
if (!slugResult.ok) return slugResult.response
|
|
147
|
+
|
|
148
|
+
const statusResult = resolveStatus(c, bodyResult.value, (currentRow.status as string) ?? 'draft')
|
|
149
|
+
if (!statusResult.ok) return statusResult.response
|
|
150
|
+
|
|
151
|
+
const dataResult = resolveData(c, seed, bodyResult.value, currentRow)
|
|
152
|
+
if (!dataResult.ok) return dataResult.response
|
|
153
|
+
|
|
154
|
+
if (slugResult.value.slugRequested) {
|
|
155
|
+
const slugExisting = await DB.prepare(`SELECT id FROM ${table} WHERE slug = ? AND id != ? LIMIT 1`)
|
|
156
|
+
.bind(slugResult.value.nextSlug, id)
|
|
157
|
+
.first<{ id: string }>()
|
|
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
|
+
}
|
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { 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 { rowToApiData } from '../shared/content-utils'
|
|
6
|
+
import { checkPublicOperation } from './access-policy'
|
|
7
|
+
import { publicProblem } from './problem-details'
|
|
8
|
+
import { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
|
|
9
|
+
import {
|
|
10
|
+
buildPublicFilterWhereClause,
|
|
11
|
+
parseLatestCount,
|
|
12
|
+
parsePublicFilter,
|
|
13
|
+
parsePublicPagination,
|
|
14
|
+
} 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
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildSeedNotFoundMessage(seed: string, seedRegistry: Record<string, Seed>): string {
|
|
31
|
+
const available = Object.keys(seedRegistry).join(', ')
|
|
32
|
+
return `The content type '${seed}' does not exist. Available types: ${available}.`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Applica policy public/visibility per la Public API. */
|
|
36
|
+
function applyPublicPolicies(aliasData: Record<string, unknown>, seed: Seed): Record<string, unknown> {
|
|
37
|
+
const result: Record<string, unknown> = {}
|
|
38
|
+
for (const branch of seed.branches) {
|
|
39
|
+
const value = aliasData[branch.alias]
|
|
40
|
+
const { public: isPublic, visibility } = resolvePolicies(branch)
|
|
41
|
+
if (!isPublic) continue
|
|
42
|
+
if (visibility === 'hidden') continue
|
|
43
|
+
if (visibility === 'masked') {
|
|
44
|
+
result[branch.alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
|
|
45
|
+
} else {
|
|
46
|
+
result[branch.alias] = value
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function toFlatPublicEntry(row: Record<string, unknown>, seed: Seed, fieldsParam?: string): Record<string, unknown> {
|
|
53
|
+
const aliasData = applyPublicPolicies(rowToApiData(seed, row), seed)
|
|
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
|
+
}
|
|
61
|
+
const requestedFields = (fieldsParam ?? '').split(',').map((f) => f.trim()).filter(Boolean)
|
|
62
|
+
if (requestedFields.length === 0) return { ...base, ...aliasData }
|
|
63
|
+
const filteredData: Record<string, unknown> = {}
|
|
64
|
+
for (const field of requestedFields) {
|
|
65
|
+
if (field in aliasData) filteredData[field] = aliasData[field]
|
|
66
|
+
}
|
|
67
|
+
return { ...base, ...filteredData }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withCache(
|
|
71
|
+
cache: Cache | undefined,
|
|
72
|
+
executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined,
|
|
73
|
+
cacheKey: Request,
|
|
74
|
+
response: Response
|
|
75
|
+
): Response {
|
|
76
|
+
if (cache && executionCtx) {
|
|
77
|
+
const cloned = response.clone()
|
|
78
|
+
const headers = new Headers(cloned.headers)
|
|
79
|
+
headers.set('Cache-Control', 'public, max-age=60')
|
|
80
|
+
executionCtx.waitUntil(cache.put(cacheKey, new Response(cloned.body, { status: cloned.status, headers })))
|
|
81
|
+
}
|
|
82
|
+
return response
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function buildInternalErrorMessage(c: Context<{ Bindings: Bindings; Variables: Variables }>, err: unknown): string {
|
|
86
|
+
if (c.env.ENV !== 'production' && err instanceof Error) return err.message
|
|
87
|
+
return 'An unexpected error occurred.'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildOrderSql(seed: Seed, query: Record<string, string | undefined>, hasLatest: boolean): string {
|
|
91
|
+
if (hasLatest) return 'ORDER BY created_at DESC'
|
|
92
|
+
const orderBy = cleanStr(query.orderBy) ?? ''
|
|
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)
|
|
109
|
+
if (!seed) {
|
|
110
|
+
return publicProblem(c, {
|
|
111
|
+
type: 'seed-not-found', title: 'Seed Not Found', status: 404,
|
|
112
|
+
detail: buildSeedNotFoundMessage(seedSlug, c.get('seedRegistry')),
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
const access = checkPublicOperation(seed, 'read')
|
|
116
|
+
if (!access.ok) {
|
|
117
|
+
return publicProblem(c, { type: 'operation-not-allowed', title: access.error.error, status: 403, detail: access.error.message })
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let cache: Cache | undefined
|
|
121
|
+
let executionCtx: { waitUntil: (p: Promise<unknown>) => void } | undefined
|
|
122
|
+
try {
|
|
123
|
+
cache = caches.default
|
|
124
|
+
executionCtx = c.executionCtx as typeof executionCtx
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
const cacheKey = c.req.raw
|
|
128
|
+
if (cache) {
|
|
129
|
+
const hit = await cache.match(cacheKey)
|
|
130
|
+
if (hit) return hit
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const query = c.req.query()
|
|
134
|
+
const id = cleanStr(query.id)
|
|
135
|
+
const table = `content_${seedSlug}`
|
|
136
|
+
const publishedOnly = c.env.PUBLIC_PUBLISHED_ONLY !== 'false'
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const { DB } = c.env
|
|
140
|
+
|
|
141
|
+
if (id) {
|
|
142
|
+
const row = await DB.prepare(
|
|
143
|
+
`SELECT * FROM ${table} WHERE id = ? ${publishedOnly ? "AND status = 'published'" : ''} LIMIT 1`
|
|
144
|
+
).bind(id).first<Record<string, unknown>>()
|
|
145
|
+
|
|
146
|
+
if (!row) {
|
|
147
|
+
return publicProblem(c, { type: 'entry-not-found', title: 'Not Found', status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return withCache(cache, executionCtx, cacheKey,
|
|
151
|
+
c.json({ data: toFlatPublicEntry(row, seed, query.fields), meta: buildPublicSingleMeta(seedSlug) }, 200)
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const parsedFilter = parsePublicFilter(query.filter)
|
|
156
|
+
const allMode = cleanStr(query.all)?.toLowerCase() === 'true'
|
|
157
|
+
const latestMode = cleanStr(query.latest) !== null
|
|
158
|
+
const latestCount = latestMode ? parseLatestCount(query.latest ?? '') : null
|
|
159
|
+
const pagination = allMode ? { page: 1, limit: 100 } : parsePublicPagination(query)
|
|
160
|
+
const offset = (pagination.page - 1) * pagination.limit
|
|
161
|
+
const search = cleanStr(query.search) ?? ''
|
|
162
|
+
|
|
163
|
+
const whereParts: string[] = []
|
|
164
|
+
const whereBindings: Array<string | number> = []
|
|
165
|
+
if (publishedOnly) whereParts.push("status = 'published'")
|
|
166
|
+
|
|
167
|
+
if (search) {
|
|
168
|
+
// Search against slug and text/richtext columns
|
|
169
|
+
const searchableBranches = seed.branches.filter((b) => ['text', 'richtext'].includes(b.type))
|
|
170
|
+
const term = `%${search}%`
|
|
171
|
+
const searchExprs = ['slug LIKE ?', ...searchableBranches.map((b) => `${b.alias} LIKE ?`)]
|
|
172
|
+
whereParts.push(`(${searchExprs.join(' OR ')})`)
|
|
173
|
+
whereBindings.push(term, ...searchableBranches.map(() => term))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const filterClause = buildPublicFilterWhereClause(seed, parsedFilter)
|
|
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>>()
|
|
195
|
+
|
|
196
|
+
const rows = rowsResult.results ?? []
|
|
197
|
+
const data = rows.map((row) => toFlatPublicEntry(row, seed, query.fields))
|
|
198
|
+
|
|
199
|
+
if (latestMode) {
|
|
200
|
+
return withCache(cache, executionCtx, cacheKey,
|
|
201
|
+
c.json({ data, meta: { total, returned: data.length, seed: seedSlug } }, 200)
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return withCache(cache, executionCtx, cacheKey,
|
|
206
|
+
c.json({
|
|
207
|
+
data,
|
|
208
|
+
meta: buildPublicListMeta({ total, page: pagination.page, limit: effectiveLimit, returned: data.length, seed: seedSlug }),
|
|
209
|
+
}, 200)
|
|
210
|
+
)
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err instanceof Error && err.message.startsWith('Invalid filter:')) {
|
|
213
|
+
return publicProblem(c, { type: 'invalid-filter', title: 'Bad Request', status: 400, detail: err.message })
|
|
214
|
+
}
|
|
215
|
+
return publicProblem(c, { type: 'internal-server-error', title: 'Internal Server Error', status: 500, detail: buildInternalErrorMessage(c, err) })
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { publicReadHandler } from './public-read'
|
|
3
|
+
import { publicAddHandler } from './public-add'
|
|
4
|
+
import { publicEditHandler } from './public-edit'
|
|
5
|
+
|
|
6
|
+
type Bindings = {
|
|
7
|
+
DB: D1Database
|
|
8
|
+
PUBLIC_READ_API_KEY?: string
|
|
9
|
+
PUBLIC_WRITE_API_KEY?: string
|
|
10
|
+
ENV?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Variables = {
|
|
14
|
+
jwtPayload: { sub: string; email?: string }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const publicApp = new Hono<{ Bindings: Bindings; Variables: Variables }>()
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Step 1 smoke endpoint to validate API key middleware flow.
|
|
21
|
+
*/
|
|
22
|
+
publicApp.get('/health', (c) => {
|
|
23
|
+
return c.json({ ok: true, service: 'public-api' }, 200)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
publicApp.get('/:seed', publicReadHandler)
|
|
27
|
+
publicApp.post('/:seed/add', publicAddHandler)
|
|
28
|
+
publicApp.put('/:seed/edit/:id', publicEditHandler)
|
|
29
|
+
|
|
30
|
+
export const publicRoutes = publicApp
|
|
31
|
+
|