@beechcms/api 0.4.0-preview.10 → 0.4.0-preview.12
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/assets/index-CTSuGxlX.js +554 -0
- package/assets/dashboard/assets/index-ye3325L9.css +1 -0
- package/assets/dashboard/index.html +2 -2
- package/package.json +2 -2
- package/src/factory.ts +96 -85
- 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/settings/settings.handler.ts +18 -0
- package/src/index.ts +16 -3
- 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/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 +6 -1
- package/src/upload.ts +3 -7
- package/assets/dashboard/assets/index-CFTJe1vb.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
package/src/factory.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
3
|
import { cors } from 'hono/cors'
|
|
4
4
|
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
5
|
-
import type { Seed } from '@beechcms/core'
|
|
5
|
+
import type { Seed, ContentRepository, IdempotencyRepository } from '@beechcms/core'
|
|
6
6
|
import type { Env, Variables } from './types'
|
|
7
7
|
|
|
8
8
|
// Imports delle rotte e middleware
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
revokeRefreshToken,
|
|
23
23
|
} from './auth/refresh'
|
|
24
24
|
import { authMiddleware } from './middleware'
|
|
25
|
-
import
|
|
25
|
+
import contentFeature from './features/content'
|
|
26
26
|
import { widgetApp } from './widget'
|
|
27
27
|
import { rotateFieldApp } from './features/rotate-field'
|
|
28
28
|
import { passwordResetApp } from './features/password-reset'
|
|
@@ -35,9 +35,12 @@ import { statsApp } from './features/stats'
|
|
|
35
35
|
import { uploadRoutes, serveMediaHandler } from './upload'
|
|
36
36
|
import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
|
|
37
37
|
import { searchRouter } from "./search"
|
|
38
|
+
import { repositoryMiddleware } from './middleware/repository.middleware'
|
|
38
39
|
|
|
39
40
|
export interface BeechConfig {
|
|
40
41
|
seeds: Seed[] | Record<string, Seed>
|
|
42
|
+
repository?: ContentRepository
|
|
43
|
+
idempotencyRepository?: IdempotencyRepository
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
// --- Costanti e helper ---
|
|
@@ -71,11 +74,11 @@ function getRefreshTokenDeleteCookieOptions(secure: boolean) {
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
function handleAuthError(
|
|
75
|
-
if (
|
|
76
|
-
console.error(`${operationName} error:`,
|
|
77
|
+
function handleAuthError(context: any, error: unknown, operationName: string): Response {
|
|
78
|
+
if (context.env.ENV !== 'production') {
|
|
79
|
+
console.error(`${operationName} error:`, error)
|
|
77
80
|
}
|
|
78
|
-
return
|
|
81
|
+
return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
function extractPublicSeed(path: string): string {
|
|
@@ -89,20 +92,28 @@ function extractPublicSeed(path: string): string {
|
|
|
89
92
|
*/
|
|
90
93
|
export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
|
|
91
94
|
const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
|
|
92
|
-
|
|
95
|
+
// Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
|
|
96
|
+
const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
|
|
97
|
+
const registry: Record<string, Seed> = Object.fromEntries(validSeeds.map(s => [s.slug, s]))
|
|
93
98
|
const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
|
|
94
99
|
|
|
95
100
|
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
96
101
|
|
|
97
102
|
// 1. Core Middleware (Seeds, CORS, Security)
|
|
98
|
-
app.use('*', async (
|
|
99
|
-
|
|
100
|
-
|
|
103
|
+
app.use('*', async (context, next) => {
|
|
104
|
+
context.set('getSeed', getSeedFn)
|
|
105
|
+
context.set('seedRegistry', registry)
|
|
101
106
|
await next()
|
|
102
107
|
})
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
109
|
+
// 1.1 Repository Injection
|
|
110
|
+
app.use('*', repositoryMiddleware({
|
|
111
|
+
repository: config.repository,
|
|
112
|
+
idempotencyRepository: config.idempotencyRepository,
|
|
113
|
+
}))
|
|
114
|
+
|
|
115
|
+
app.use('*', async (context, next) => {
|
|
116
|
+
const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
|
106
117
|
.split(',')
|
|
107
118
|
.map((o) => o.trim())
|
|
108
119
|
.filter(Boolean)
|
|
@@ -114,7 +125,7 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
114
125
|
// Allow same-origin if the host matches
|
|
115
126
|
try {
|
|
116
127
|
const originUrl = new URL(origin)
|
|
117
|
-
const requestUrl = new URL(
|
|
128
|
+
const requestUrl = new URL(context.req.url)
|
|
118
129
|
if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
119
130
|
return origin
|
|
120
131
|
}
|
|
@@ -125,29 +136,29 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
125
136
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
126
137
|
allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
|
|
127
138
|
credentials: true,
|
|
128
|
-
})(
|
|
139
|
+
})(context, next)
|
|
129
140
|
})
|
|
130
141
|
|
|
131
|
-
app.use('*', async (
|
|
142
|
+
app.use('*', async (context, next) => {
|
|
132
143
|
await next()
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
144
|
+
if (context.req.path.startsWith('/admin')) return
|
|
145
|
+
context.header('X-Frame-Options', 'DENY')
|
|
146
|
+
context.header('X-Content-Type-Options', 'nosniff')
|
|
147
|
+
context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
148
|
+
context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
|
|
149
|
+
context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
|
|
139
150
|
})
|
|
140
151
|
|
|
141
152
|
// 2. Analytics Middleware
|
|
142
|
-
app.use('/api/*', async (
|
|
153
|
+
app.use('/api/*', async (context, next) => {
|
|
143
154
|
await next()
|
|
144
|
-
if (
|
|
145
|
-
const db =
|
|
155
|
+
if (context.req.method !== 'OPTIONS' && context.res.status >= 200 && context.res.status < 300) {
|
|
156
|
+
const db = context.env.DB
|
|
146
157
|
let executionCtx: any
|
|
147
|
-
try { executionCtx =
|
|
158
|
+
try { executionCtx = context.executionCtx } catch {}
|
|
148
159
|
|
|
149
160
|
if (db && executionCtx) {
|
|
150
|
-
const seed = extractPublicSeed(
|
|
161
|
+
const seed = extractPublicSeed(context.req.path)
|
|
151
162
|
executionCtx.waitUntil((async () => {
|
|
152
163
|
try {
|
|
153
164
|
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
|
|
@@ -156,8 +167,8 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
156
167
|
VALUES (?, 'requests', ?, 1)
|
|
157
168
|
ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
|
|
158
169
|
).bind(today, seed).run()
|
|
159
|
-
} catch (
|
|
160
|
-
console.error('Analytics middleware error:',
|
|
170
|
+
} catch (error) {
|
|
171
|
+
console.error('Analytics middleware error:', error)
|
|
161
172
|
}
|
|
162
173
|
})())
|
|
163
174
|
}
|
|
@@ -165,88 +176,88 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
165
176
|
})
|
|
166
177
|
|
|
167
178
|
// 3. Auth Routes
|
|
168
|
-
app.post('/auth/login', async (
|
|
179
|
+
app.post('/auth/login', async (context) => {
|
|
169
180
|
try {
|
|
170
181
|
let body: any
|
|
171
|
-
try { body = await
|
|
182
|
+
try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
|
|
172
183
|
const credentials = parseLoginBody(body)
|
|
173
|
-
if (!credentials) return
|
|
184
|
+
if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
174
185
|
const { email, password } = credentials
|
|
175
|
-
if (!validateLoginInput(email, password)) return
|
|
186
|
+
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
176
187
|
|
|
177
|
-
const loginLimiter =
|
|
188
|
+
const loginLimiter = context.env.LOGIN_RATE_LIMITER
|
|
178
189
|
if (loginLimiter) {
|
|
179
|
-
const clientIp = getClientIp(
|
|
190
|
+
const clientIp = getClientIp(context.req.raw.headers)
|
|
180
191
|
const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
|
|
181
|
-
if (!success) return
|
|
192
|
+
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
const { DB, JWT_SECRET } =
|
|
195
|
+
const { DB, JWT_SECRET } = context.env
|
|
185
196
|
const user = await findUserByEmail(DB, email)
|
|
186
197
|
const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
|
|
187
198
|
const isValid = await verifyPassword(password, hashToCompare)
|
|
188
199
|
|
|
189
|
-
if (!user || !isValid) return
|
|
200
|
+
if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
|
|
190
201
|
|
|
191
202
|
const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
|
|
192
203
|
const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
193
|
-
issuer:
|
|
194
|
-
audience:
|
|
204
|
+
issuer: context.env.JWT_ISSUER,
|
|
205
|
+
audience: context.env.JWT_AUDIENCE,
|
|
195
206
|
}, userProfile?.name ?? undefined)
|
|
196
207
|
const refreshToken = generateRefreshToken()
|
|
197
208
|
|
|
198
209
|
await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
199
|
-
setCookie(
|
|
200
|
-
return
|
|
201
|
-
} catch (
|
|
202
|
-
return handleAuthError(
|
|
210
|
+
setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
211
|
+
return context.json({ token: accessToken, expiresIn: '15m' }, 200)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
return handleAuthError(context, error, 'Login')
|
|
203
214
|
}
|
|
204
215
|
})
|
|
205
216
|
|
|
206
|
-
app.post('/auth/refresh', async (
|
|
217
|
+
app.post('/auth/refresh', async (context) => {
|
|
207
218
|
try {
|
|
208
|
-
const refreshLimiter =
|
|
219
|
+
const refreshLimiter = context.env.REFRESH_RATE_LIMITER
|
|
209
220
|
if (refreshLimiter) {
|
|
210
|
-
const clientIp = getClientIp(
|
|
221
|
+
const clientIp = getClientIp(context.req.raw.headers)
|
|
211
222
|
const { success } = await refreshLimiter.limit({ key: clientIp })
|
|
212
|
-
if (!success) return
|
|
223
|
+
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
213
224
|
}
|
|
214
225
|
|
|
215
|
-
const refreshToken = getCookie(
|
|
216
|
-
if (!refreshToken) return
|
|
226
|
+
const refreshToken = getCookie(context, 'refresh_token')
|
|
227
|
+
if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
|
|
217
228
|
|
|
218
|
-
const { DB, JWT_SECRET } =
|
|
229
|
+
const { DB, JWT_SECRET } = context.env
|
|
219
230
|
const validation = await validateRefreshToken(DB, refreshToken)
|
|
220
|
-
if (!validation.valid || !validation.userId) return
|
|
231
|
+
if (!validation.valid || !validation.userId) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
221
232
|
|
|
222
233
|
const user = await DB.prepare('SELECT id, email, name FROM users WHERE id = ? LIMIT 1').bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
|
|
223
|
-
if (!user) return
|
|
234
|
+
if (!user) return context.json({ error: 'User not found' }, 401)
|
|
224
235
|
|
|
225
236
|
const revoked = await revokeRefreshToken(DB, refreshToken)
|
|
226
|
-
if (!revoked) return
|
|
237
|
+
if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
227
238
|
|
|
228
239
|
const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
229
|
-
issuer:
|
|
230
|
-
audience:
|
|
240
|
+
issuer: context.env.JWT_ISSUER,
|
|
241
|
+
audience: context.env.JWT_AUDIENCE,
|
|
231
242
|
}, user.name ?? undefined)
|
|
232
243
|
const newRefreshToken = generateRefreshToken()
|
|
233
244
|
|
|
234
245
|
await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
235
|
-
setCookie(
|
|
236
|
-
return
|
|
237
|
-
} catch (
|
|
238
|
-
return handleAuthError(
|
|
246
|
+
setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
247
|
+
return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
|
|
248
|
+
} catch (error) {
|
|
249
|
+
return handleAuthError(context, error, 'Refresh')
|
|
239
250
|
}
|
|
240
251
|
})
|
|
241
252
|
|
|
242
|
-
app.post('/auth/logout', async (
|
|
253
|
+
app.post('/auth/logout', async (context) => {
|
|
243
254
|
try {
|
|
244
|
-
const refreshToken = getCookie(
|
|
245
|
-
if (refreshToken) await revokeRefreshToken(
|
|
246
|
-
deleteCookie(
|
|
247
|
-
return
|
|
248
|
-
} catch (
|
|
249
|
-
return handleAuthError(
|
|
255
|
+
const refreshToken = getCookie(context, 'refresh_token')
|
|
256
|
+
if (refreshToken) await revokeRefreshToken(context.env.DB, refreshToken)
|
|
257
|
+
deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
|
|
258
|
+
return context.json({ message: 'Logged out' }, 200)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
return handleAuthError(context, error, 'Logout')
|
|
250
261
|
}
|
|
251
262
|
})
|
|
252
263
|
|
|
@@ -256,11 +267,11 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
256
267
|
|
|
257
268
|
// 5. Protected CMS API
|
|
258
269
|
const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
259
|
-
apiProtected.use('*', async (
|
|
260
|
-
await authMiddleware(
|
|
261
|
-
issuer:
|
|
262
|
-
audience:
|
|
263
|
-
})(
|
|
270
|
+
apiProtected.use('*', async (context, next) => {
|
|
271
|
+
await authMiddleware(context.env.JWT_SECRET, {
|
|
272
|
+
issuer: context.env.JWT_ISSUER,
|
|
273
|
+
audience: context.env.JWT_AUDIENCE,
|
|
274
|
+
})(context, next)
|
|
264
275
|
})
|
|
265
276
|
|
|
266
277
|
apiProtected.route('/settings', settingsApp)
|
|
@@ -269,35 +280,35 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
269
280
|
apiProtected.route('/content', statsApp)
|
|
270
281
|
apiProtected.route('/content', rotateFieldApp)
|
|
271
282
|
apiProtected.route('/content', draftApp)
|
|
272
|
-
apiProtected.route('/content',
|
|
283
|
+
apiProtected.route('/content', contentFeature)
|
|
273
284
|
apiProtected.route('/widget', widgetApp)
|
|
285
|
+
apiProtected.route('/', uploadRoutes)
|
|
274
286
|
|
|
275
|
-
|
|
276
|
-
app.route('/api/search', searchRouter)
|
|
277
|
-
app.route('/api', uploadRoutes)
|
|
278
|
-
app.get('/api/media/:key', (c) => serveMediaHandler(c))
|
|
279
|
-
|
|
280
|
-
// 6. Public API
|
|
287
|
+
// 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
|
|
281
288
|
const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
282
289
|
apiPublic.use('*', publicRateLimitMiddleware())
|
|
283
290
|
apiPublic.use('*', apiKeyMiddleware())
|
|
284
291
|
apiPublic.route('/', publicRoutes)
|
|
285
292
|
app.route('/api/v1/public', apiPublic)
|
|
286
293
|
|
|
294
|
+
app.get('/api/media/:key', (context) => serveMediaHandler(context))
|
|
295
|
+
app.route('/api', apiProtected)
|
|
296
|
+
app.route('/api/search', searchRouter)
|
|
297
|
+
|
|
287
298
|
// 7. Dashboard SPA — serve static assets from Workers Assets binding
|
|
288
|
-
app.get('/admin', (
|
|
289
|
-
app.get('/admin/*', async (
|
|
290
|
-
if (!
|
|
291
|
-
return
|
|
299
|
+
app.get('/admin', (context) => context.redirect('/admin/', 301))
|
|
300
|
+
app.get('/admin/*', async (context) => {
|
|
301
|
+
if (!context.env.ASSETS) {
|
|
302
|
+
return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
|
|
292
303
|
}
|
|
293
|
-
const url = new URL(
|
|
304
|
+
const url = new URL(context.req.url)
|
|
294
305
|
const originalPath = url.pathname
|
|
295
306
|
url.pathname = originalPath.replace(/^\/admin/, '') || '/'
|
|
296
307
|
|
|
297
|
-
let assetResponse = await
|
|
308
|
+
let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
|
|
298
309
|
if (assetResponse.status === 404) {
|
|
299
310
|
// SPA fallback: any unmatched /admin/* route serves index.html
|
|
300
|
-
assetResponse = await
|
|
311
|
+
assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
|
|
301
312
|
}
|
|
302
313
|
// ASSETS returns an immutable Response — wrap it to inject security headers
|
|
303
314
|
const headers = new Headers(assetResponse.headers)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const CONTENT_ERRORS = {
|
|
2
|
+
INVALID_SLUG: 'Invalid slug',
|
|
3
|
+
INVALID_SLUG_OR_ID: 'Invalid slug or id',
|
|
4
|
+
INVALID_JSON_BODY: 'Invalid JSON body',
|
|
5
|
+
NOT_FOUND: 'Not found',
|
|
6
|
+
SEED_NOT_FOUND: 'Seed not found',
|
|
7
|
+
DATABASE_ERROR: 'Database error',
|
|
8
|
+
SLUG_CONFLICT: 'Slug already exists for this schema',
|
|
9
|
+
SENSITIVE_FIELD_EDIT: 'Cannot edit sensitive fields',
|
|
10
|
+
} as const
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
import {
|
|
3
|
+
slugify,
|
|
4
|
+
isValidContentStatus,
|
|
5
|
+
validateAndSanitizeSeedPayload,
|
|
6
|
+
SlugConflictError
|
|
7
|
+
} from '@beechcms/core'
|
|
8
|
+
import { applyPrivacy, PrivacyPolicyError } from '../../../shared/apply-policies'
|
|
9
|
+
import { publicProblem } from '../../../public/problem-details'
|
|
10
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
11
|
+
import { logActivity } from '../../../shared/activity-logger'
|
|
12
|
+
import { logContentEvent } from '../../../shared/content-utils'
|
|
13
|
+
import { cleanStr } from '../../../shared/query-utils'
|
|
14
|
+
import { AppEnv } from '../../../types'
|
|
15
|
+
|
|
16
|
+
function normalizeBody(raw: unknown): Record<string, unknown> {
|
|
17
|
+
return typeof raw === 'object' && raw !== null ? (raw as Record<string, unknown>) : {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function contentValidationProblem(
|
|
21
|
+
context: Context,
|
|
22
|
+
details: Array<{ field: string; expected: string; received: string; message: string }>
|
|
23
|
+
) {
|
|
24
|
+
return publicProblem(context, {
|
|
25
|
+
type: 'content-validation-failed',
|
|
26
|
+
title: 'Bad Request',
|
|
27
|
+
status: 400,
|
|
28
|
+
detail: 'Validation failed',
|
|
29
|
+
errors: details
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function createHandler(context: Context<AppEnv>) {
|
|
34
|
+
const slug = context.req.param('slug')
|
|
35
|
+
if (!slug) {
|
|
36
|
+
return publicProblem(context, {
|
|
37
|
+
type: 'content-invalid-slug',
|
|
38
|
+
title: 'Bad Request',
|
|
39
|
+
status: 400,
|
|
40
|
+
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const seed = context.get('getSeed')(slug)
|
|
45
|
+
if (!seed) {
|
|
46
|
+
return publicProblem(context, {
|
|
47
|
+
type: 'content-seed-not-found',
|
|
48
|
+
title: 'Not Found',
|
|
49
|
+
status: 404,
|
|
50
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let body: Record<string, unknown>
|
|
55
|
+
try {
|
|
56
|
+
body = normalizeBody(await context.req.json<unknown>())
|
|
57
|
+
} catch {
|
|
58
|
+
return publicProblem(context, {
|
|
59
|
+
type: 'content-invalid-json',
|
|
60
|
+
title: 'Bad Request',
|
|
61
|
+
status: 400,
|
|
62
|
+
detail: CONTENT_ERRORS.INVALID_JSON_BODY
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const entrySlug = body.slug ? slugify(String(body.slug)) : null
|
|
67
|
+
const status = cleanStr(body.status) ?? 'draft'
|
|
68
|
+
if (!isValidContentStatus(status)) {
|
|
69
|
+
return publicProblem(context, {
|
|
70
|
+
type: 'content-invalid-status',
|
|
71
|
+
title: 'Bad Request',
|
|
72
|
+
status: 400,
|
|
73
|
+
detail: 'Invalid status. Allowed values are: draft, review, published'
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bodyForData = { ...body }
|
|
78
|
+
delete bodyForData.slug
|
|
79
|
+
delete bodyForData.status
|
|
80
|
+
|
|
81
|
+
const validation = validateAndSanitizeSeedPayload(seed, bodyForData, {
|
|
82
|
+
operation: 'create',
|
|
83
|
+
allowNull: false,
|
|
84
|
+
requireAtLeastOneValidField: true,
|
|
85
|
+
enforceRequiredFields: true,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
if (validation.dangerousFields.length > 0) {
|
|
89
|
+
return publicProblem(context, {
|
|
90
|
+
type: 'content-dangerous-content',
|
|
91
|
+
title: 'Unprocessable Entity',
|
|
92
|
+
status: 422,
|
|
93
|
+
detail: `Content rejected: dangerous markup detected in field '${validation.dangerousFields[0]}'`
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (validation.details.length > 0) return contentValidationProblem(context, validation.details)
|
|
98
|
+
|
|
99
|
+
let privacyData: Record<string, unknown>
|
|
100
|
+
try {
|
|
101
|
+
privacyData = await applyPrivacy(validation.data, seed)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof PrivacyPolicyError) {
|
|
104
|
+
return publicProblem(context, {
|
|
105
|
+
type: 'content-policy-not-implemented',
|
|
106
|
+
title: 'Not Implemented',
|
|
107
|
+
status: 501,
|
|
108
|
+
detail: error.message
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
throw error
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const id = crypto.randomUUID()
|
|
115
|
+
let finalSlug = entrySlug
|
|
116
|
+
if (!finalSlug) {
|
|
117
|
+
const fallbackSource = privacyData[seed.displayNameAlias ?? 'title'] || privacyData.title || privacyData.name || id
|
|
118
|
+
finalSlug = slugify(String(fallbackSource))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const repository = context.get('repository')
|
|
123
|
+
await repository.create(seed, id, finalSlug, status, privacyData)
|
|
124
|
+
|
|
125
|
+
const userId = context.get('jwtPayload')?.sub
|
|
126
|
+
const title = privacyData.title || privacyData.name || finalSlug
|
|
127
|
+
|
|
128
|
+
// We use the DB from env for legacy logging utils until they are migrated
|
|
129
|
+
logContentEvent(context.env.DB, {
|
|
130
|
+
action: 'create',
|
|
131
|
+
schemaSlug: slug,
|
|
132
|
+
entryId: id,
|
|
133
|
+
userId,
|
|
134
|
+
details: { title }
|
|
135
|
+
}).catch(() => {})
|
|
136
|
+
|
|
137
|
+
logActivity(context, {
|
|
138
|
+
action: 'create',
|
|
139
|
+
entityType: 'content',
|
|
140
|
+
entityId: id,
|
|
141
|
+
entitySlug: slug,
|
|
142
|
+
details: { title }
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
return context.json({ id }, 201)
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof SlugConflictError) {
|
|
148
|
+
return publicProblem(context, {
|
|
149
|
+
type: 'content-slug-conflict',
|
|
150
|
+
title: 'Conflict',
|
|
151
|
+
status: 409,
|
|
152
|
+
detail: CONTENT_ERRORS.SLUG_CONFLICT
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
console.error('Content create error:', error)
|
|
156
|
+
return publicProblem(context, {
|
|
157
|
+
type: 'content-database-error',
|
|
158
|
+
title: 'Internal Server Error',
|
|
159
|
+
status: 500,
|
|
160
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
import { EntryNotFoundError } from '@beechcms/core'
|
|
3
|
+
import { deleteR2Objects } from '../../../upload'
|
|
4
|
+
import { extractMediaKeysFromData } from '../../../media-utils'
|
|
5
|
+
import { publicProblem } from '../../../public/problem-details'
|
|
6
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
7
|
+
import { logActivity } from '../../../shared/activity-logger'
|
|
8
|
+
import { logContentEvent } from '../../../shared/content-utils'
|
|
9
|
+
import { AppEnv } from '../../../types'
|
|
10
|
+
|
|
11
|
+
export async function deleteHandler(context: Context<AppEnv>) {
|
|
12
|
+
const schemaSlug = context.req.param('slug')
|
|
13
|
+
const entryId = context.req.param('id')
|
|
14
|
+
if (!schemaSlug || !entryId) {
|
|
15
|
+
return publicProblem(context, {
|
|
16
|
+
type: 'content-invalid-slug-or-id',
|
|
17
|
+
title: 'Bad Request',
|
|
18
|
+
status: 400,
|
|
19
|
+
detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const seed = context.get('getSeed')(schemaSlug)
|
|
24
|
+
if (!seed) {
|
|
25
|
+
return publicProblem(context, {
|
|
26
|
+
type: 'content-seed-not-found',
|
|
27
|
+
title: 'Not Found',
|
|
28
|
+
status: 404,
|
|
29
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const repository = context.get('repository')
|
|
35
|
+
// Repository.delete returns the row data for cleanup
|
|
36
|
+
const { row } = await repository.delete(seed, entryId)
|
|
37
|
+
|
|
38
|
+
const userId = context.get('jwtPayload')?.sub
|
|
39
|
+
const title = row.title || row.name || entryId
|
|
40
|
+
|
|
41
|
+
logContentEvent(context.env.DB, {
|
|
42
|
+
action: 'delete',
|
|
43
|
+
schemaSlug,
|
|
44
|
+
entryId,
|
|
45
|
+
userId,
|
|
46
|
+
details: { title }
|
|
47
|
+
}).catch(() => {})
|
|
48
|
+
|
|
49
|
+
logActivity(context, {
|
|
50
|
+
action: 'delete',
|
|
51
|
+
entityType: 'content',
|
|
52
|
+
entityId: entryId,
|
|
53
|
+
entitySlug: schemaSlug,
|
|
54
|
+
details: { title }
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// Task 4.3: Cleanup R2 logic
|
|
58
|
+
const r2ObjectKeys = extractMediaKeysFromData(seed, row)
|
|
59
|
+
if (r2ObjectKeys.length > 0) {
|
|
60
|
+
await deleteR2Objects(context.env, r2ObjectKeys).catch((error) => {
|
|
61
|
+
if (context.env.ENV !== 'production') {
|
|
62
|
+
console.warn('R2 cleanup on delete failed (orphaned files):', error)
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return context.json({ success: true })
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error instanceof EntryNotFoundError) {
|
|
70
|
+
return publicProblem(context, {
|
|
71
|
+
type: 'content-not-found',
|
|
72
|
+
title: 'Not Found',
|
|
73
|
+
status: 404,
|
|
74
|
+
detail: CONTENT_ERRORS.NOT_FOUND
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
console.error('Content delete error:', error)
|
|
78
|
+
return publicProblem(context, {
|
|
79
|
+
type: 'content-database-error',
|
|
80
|
+
title: 'Internal Server Error',
|
|
81
|
+
status: 500,
|
|
82
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
import { publicProblem } from '../../../public/problem-details'
|
|
3
|
+
import { CONTENT_ERRORS } from '../constants'
|
|
4
|
+
import { AppEnv } from '../../../types'
|
|
5
|
+
|
|
6
|
+
export async function facetsHandler(context: Context<AppEnv>) {
|
|
7
|
+
const slug = context.req.param('slug')
|
|
8
|
+
if (!slug) {
|
|
9
|
+
return publicProblem(context, {
|
|
10
|
+
type: 'content-invalid-slug',
|
|
11
|
+
title: 'Bad Request',
|
|
12
|
+
status: 400,
|
|
13
|
+
detail: CONTENT_ERRORS.INVALID_SLUG
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const seed = context.get('getSeed')(slug)
|
|
18
|
+
if (!seed) {
|
|
19
|
+
return publicProblem(context, {
|
|
20
|
+
type: 'content-seed-not-found',
|
|
21
|
+
title: 'Not Found',
|
|
22
|
+
status: 404,
|
|
23
|
+
detail: CONTENT_ERRORS.SEED_NOT_FOUND
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const repository = context.get('repository')
|
|
29
|
+
const { statuses, tagsByColumn } = await repository.getFacets(seed)
|
|
30
|
+
|
|
31
|
+
// Legacy format expected by the dashboard
|
|
32
|
+
return context.json({
|
|
33
|
+
statuses: Object.keys(statuses).sort((a, b) => a.localeCompare(b, 'it')),
|
|
34
|
+
tagsByColumnId: tagsByColumn,
|
|
35
|
+
})
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('Content facets error:', error)
|
|
38
|
+
return publicProblem(context, {
|
|
39
|
+
type: 'content-database-error',
|
|
40
|
+
title: 'Internal Server Error',
|
|
41
|
+
status: 500,
|
|
42
|
+
detail: CONTENT_ERRORS.DATABASE_ERROR
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|