@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
package/src/index.ts
CHANGED
|
@@ -1,344 +1,24 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { Hono } from 'hono'
|
|
3
|
-
import type { Context } from 'hono'
|
|
4
|
-
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
5
1
|
import { createBeechApp } from './factory'
|
|
6
|
-
import { SEED_REGISTRY } from '@beechcms/core'
|
|
7
|
-
import { AUTH_ERRORS } from './auth/constants'
|
|
8
|
-
import {
|
|
9
|
-
parseLoginBody,
|
|
10
|
-
validateLoginInput,
|
|
11
|
-
findUserByEmail,
|
|
12
|
-
verifyPassword,
|
|
13
|
-
DUMMY_PASSWORD_HASH,
|
|
14
|
-
} from './auth/login'
|
|
15
|
-
import {
|
|
16
|
-
generateRefreshToken,
|
|
17
|
-
saveRefreshToken,
|
|
18
|
-
generateAccessToken,
|
|
19
|
-
validateRefreshToken,
|
|
20
|
-
revokeRefreshToken,
|
|
21
|
-
} from './auth/refresh'
|
|
22
|
-
import { authMiddleware } from './middleware'
|
|
23
|
-
import { contentRoutes } from './content'
|
|
24
|
-
import { widgetApp } from './widget'
|
|
25
|
-
// TODO: refactor — rotate-field è una VSA slice. Il resto di index.ts (auth inline, content monolith)
|
|
26
|
-
// va migrato a slices dedicati sotto src/features/ seguendo lo stesso pattern.
|
|
27
|
-
import { rotateFieldApp } from './features/rotate-field'
|
|
28
|
-
import { passwordResetApp } from './features/password-reset'
|
|
29
|
-
import { setupApp } from './features/setup'
|
|
30
|
-
import { draftApp } from './features/draft'
|
|
31
|
-
import { settingsApp } from './features/settings/settings.handler'
|
|
32
|
-
import { notificationsApp } from './features/notifications'
|
|
33
|
-
import { statsApp } from './features/stats'
|
|
34
|
-
import { uploadRoutes, serveMediaHandler } from './upload'
|
|
35
|
-
import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
|
|
36
|
-
import { searchRouter } from "./search"
|
|
37
|
-
import type { Env, Variables } from './types'
|
|
38
2
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return headers.get('cf-connecting-ip') ?? 'unknown'
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Opzioni comuni per il cookie refresh_token */
|
|
58
|
-
function getRefreshTokenCookieOptions(secure: boolean) {
|
|
59
|
-
return {
|
|
60
|
-
httpOnly: true,
|
|
61
|
-
secure,
|
|
62
|
-
sameSite: 'Strict' as const,
|
|
63
|
-
maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
|
|
64
|
-
path: '/auth',
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function getRefreshTokenDeleteCookieOptions(secure: boolean) {
|
|
69
|
-
return {
|
|
70
|
-
httpOnly: true,
|
|
71
|
-
secure,
|
|
72
|
-
sameSite: 'Strict' as const,
|
|
73
|
-
path: '/auth',
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Logga l'errore solo in sviluppo e restituisce risposta 500 generica */
|
|
78
|
-
function handleAuthError(
|
|
79
|
-
c: Context<{ Bindings: Env; Variables: Variables }>,
|
|
80
|
-
err: unknown,
|
|
81
|
-
operationName: string
|
|
82
|
-
): Response {
|
|
83
|
-
if (c.env.ENV !== 'production') {
|
|
84
|
-
console.error(`${operationName} error:`, err)
|
|
85
|
-
}
|
|
86
|
-
return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// --- App ---
|
|
90
|
-
|
|
91
|
-
const app = createBeechApp({ seeds: Object.values(SEED_REGISTRY) })
|
|
92
|
-
|
|
93
|
-
// Rota root di test
|
|
94
|
-
app.get('/', (c) => c.text('Beech API is running!'))
|
|
95
|
-
|
|
96
|
-
/** Estrae il seed slug dalle route pubbliche (/api/v1/public/:seed).
|
|
97
|
-
* Restituisce '' per tutte le altre route (metrica globale). */
|
|
98
|
-
function extractPublicSeed(path: string): string {
|
|
99
|
-
const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
|
|
100
|
-
return match ? match[1] : ''
|
|
3
|
+
/**
|
|
4
|
+
* Entry point per lo sviluppo locale del monorepo.
|
|
5
|
+
* Carica dinamicamente seed.ts o seeds.ts dalla root di apps/api se presenti.
|
|
6
|
+
*/
|
|
7
|
+
let seeds: any = []
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
// @ts-ignore
|
|
11
|
+
const mod = await import('../seed.ts')
|
|
12
|
+
const registry = mod.default || mod.SEED_REGISTRY || mod
|
|
13
|
+
seeds = (typeof registry === 'object' && !Array.isArray(registry))
|
|
14
|
+
? Object.values(registry)
|
|
15
|
+
: registry
|
|
16
|
+
} catch (e) {
|
|
17
|
+
// Fallback se seed.ts non esiste
|
|
101
18
|
}
|
|
102
19
|
|
|
103
|
-
|
|
104
|
-
app.use('/api/*', async (c, next) => {
|
|
105
|
-
await next()
|
|
106
|
-
|
|
107
|
-
// Tracciamo solo richieste andate a buon fine (2xx) e non OPTIONS
|
|
108
|
-
if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
|
|
109
|
-
const db = c.env.DB
|
|
110
|
-
// Protezione per ambienti (es. test) dove executionCtx non è definito
|
|
111
|
-
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
112
|
-
try {
|
|
113
|
-
executionCtx = c.executionCtx
|
|
114
|
-
} catch {
|
|
115
|
-
// In ambiente di test Hono lancia se non presente
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (db && executionCtx) {
|
|
119
|
-
// Usa waitUntil per non bloccare la risposta al client
|
|
120
|
-
const seed = extractPublicSeed(c.req.path)
|
|
121
|
-
executionCtx.waitUntil((async () => {
|
|
122
|
-
try {
|
|
123
|
-
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
|
|
124
|
-
|
|
125
|
-
// Incrementa contatore richieste: seed='' per route interne, seed='articoli' per public API
|
|
126
|
-
await db.prepare(
|
|
127
|
-
`INSERT INTO analytics (day_ts, metric, seed, value)
|
|
128
|
-
VALUES (?, 'requests', ?, 1)
|
|
129
|
-
ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
|
|
130
|
-
).bind(today, seed).run()
|
|
131
|
-
} catch (err) {
|
|
132
|
-
console.error('Analytics middleware error:', err)
|
|
133
|
-
}
|
|
134
|
-
})())
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
})
|
|
138
|
-
|
|
139
|
-
// POST /auth/login: autenticazione con email e password + refresh token
|
|
140
|
-
app.post('/auth/login', async (c) => {
|
|
141
|
-
try {
|
|
142
|
-
let body: unknown
|
|
143
|
-
try {
|
|
144
|
-
body = await c.req.json()
|
|
145
|
-
} catch {
|
|
146
|
-
return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const credentials = parseLoginBody(body)
|
|
150
|
-
if (!credentials) {
|
|
151
|
-
return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const { email, password } = credentials
|
|
155
|
-
if (!validateLoginInput(email, password)) {
|
|
156
|
-
return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Rate limiting: 5 tentativi per IP+email ogni 60 secondi
|
|
160
|
-
const loginLimiter = c.env.LOGIN_RATE_LIMITER
|
|
161
|
-
if (loginLimiter) {
|
|
162
|
-
const clientIp = getClientIp(c.req.raw.headers)
|
|
163
|
-
const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
|
|
164
|
-
if (!success) {
|
|
165
|
-
return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const { DB, JWT_SECRET } = c.env
|
|
170
|
-
const user = await findUserByEmail(DB, email)
|
|
171
|
-
// Sempre verifyPassword per evitare timing attack (utente non trovato vs password errata)
|
|
172
|
-
const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
|
|
173
|
-
const isValid = await verifyPassword(password, hashToCompare)
|
|
174
|
-
|
|
175
|
-
if (!user || !isValid) {
|
|
176
|
-
return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Recupera name per includerlo nel JWT
|
|
180
|
-
const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
|
|
181
|
-
|
|
182
|
-
// Genera access token (15min) e refresh token (7 giorni)
|
|
183
|
-
const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
184
|
-
issuer: c.env.JWT_ISSUER,
|
|
185
|
-
audience: c.env.JWT_AUDIENCE,
|
|
186
|
-
}, userProfile?.name ?? undefined)
|
|
187
|
-
const refreshToken = generateRefreshToken()
|
|
188
|
-
|
|
189
|
-
// Salva refresh token in DB (hashed)
|
|
190
|
-
await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
191
|
-
|
|
192
|
-
setCookie(c, 'refresh_token', refreshToken, {
|
|
193
|
-
...getRefreshTokenCookieOptions(isRequestSecure(c.req.url)),
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
// Restituisci solo access token nel body
|
|
197
|
-
return c.json({ token: accessToken, expiresIn: '15m' }, 200)
|
|
198
|
-
} catch (err) {
|
|
199
|
-
return handleAuthError(c, err, 'Login')
|
|
200
|
-
}
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
// POST /auth/refresh: ottieni nuovo access token usando refresh token
|
|
204
|
-
app.post('/auth/refresh', async (c) => {
|
|
205
|
-
try {
|
|
206
|
-
// Rate limiting: 20 richieste per IP ogni 60 secondi
|
|
207
|
-
const refreshLimiter = c.env.REFRESH_RATE_LIMITER
|
|
208
|
-
if (refreshLimiter) {
|
|
209
|
-
const clientIp = getClientIp(c.req.raw.headers)
|
|
210
|
-
const { success } = await refreshLimiter.limit({ key: clientIp })
|
|
211
|
-
if (!success) {
|
|
212
|
-
return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Leggi refresh token dal cookie usando helper Hono
|
|
217
|
-
const refreshToken = getCookie(c, 'refresh_token')
|
|
218
|
-
|
|
219
|
-
if (!refreshToken) {
|
|
220
|
-
return c.json({ error: 'Refresh token missing' }, 401)
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const { DB, JWT_SECRET } = c.env
|
|
224
|
-
|
|
225
|
-
// Valida refresh token
|
|
226
|
-
const validation = await validateRefreshToken(DB, refreshToken)
|
|
227
|
-
if (!validation.valid || !validation.userId) {
|
|
228
|
-
return c.json({ error: 'Invalid refresh token' }, 401)
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// Ottieni info utente per generare nuovo access token
|
|
232
|
-
const user = await DB.prepare(
|
|
233
|
-
'SELECT id, email, name FROM users WHERE id = ? LIMIT 1'
|
|
234
|
-
).bind(validation.userId).first<{ id: string; email: string; name: string | null }>()
|
|
235
|
-
|
|
236
|
-
if (!user) {
|
|
237
|
-
return c.json({ error: 'User not found' }, 401)
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ROTAZIONE: Invalida vecchio refresh token
|
|
241
|
-
const revoked = await revokeRefreshToken(DB, refreshToken)
|
|
242
|
-
if (!revoked) {
|
|
243
|
-
// Token già consumato/revocato (race) o appena scaduto: tratta come invalido.
|
|
244
|
-
return c.json({ error: 'Invalid refresh token' }, 401)
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// Genera NUOVO access token e NUOVO refresh token
|
|
248
|
-
const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
249
|
-
issuer: c.env.JWT_ISSUER,
|
|
250
|
-
audience: c.env.JWT_AUDIENCE,
|
|
251
|
-
}, user.name ?? undefined)
|
|
252
|
-
const newRefreshToken = generateRefreshToken()
|
|
253
|
-
|
|
254
|
-
// Salva nuovo refresh token in DB
|
|
255
|
-
await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
256
|
-
|
|
257
|
-
setCookie(c, 'refresh_token', newRefreshToken, {
|
|
258
|
-
...getRefreshTokenCookieOptions(isRequestSecure(c.req.url)),
|
|
259
|
-
})
|
|
260
|
-
|
|
261
|
-
// Restituisci nuovo access token
|
|
262
|
-
return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
|
|
263
|
-
} catch (err) {
|
|
264
|
-
return handleAuthError(c, err, 'Refresh')
|
|
265
|
-
}
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
// POST /auth/logout: invalida refresh token e cancella cookie
|
|
269
|
-
app.post('/auth/logout', async (c) => {
|
|
270
|
-
try {
|
|
271
|
-
// Leggi refresh token dal cookie usando helper Hono
|
|
272
|
-
const refreshToken = getCookie(c, 'refresh_token')
|
|
273
|
-
|
|
274
|
-
if (refreshToken) {
|
|
275
|
-
await revokeRefreshToken(c.env.DB, refreshToken)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
deleteCookie(c, 'refresh_token', {
|
|
279
|
-
...getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)),
|
|
280
|
-
})
|
|
281
|
-
|
|
282
|
-
return c.json({ message: 'Logged out' }, 200)
|
|
283
|
-
} catch (err) {
|
|
284
|
-
return handleAuthError(c, err, 'Logout')
|
|
285
|
-
}
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
// First-run setup: GET /auth/setup, POST /auth/setup (pubblici, bloccati dopo il primo utente)
|
|
289
|
-
app.route('/', setupApp)
|
|
290
|
-
|
|
291
|
-
// Password reset: GET /auth/features, POST /auth/forgot-password, POST /auth/reset-password (pubblici)
|
|
292
|
-
app.route('/', passwordResetApp)
|
|
293
|
-
|
|
294
|
-
// API Settings: gestione profilo e preferenze utente, protetto da JWT
|
|
295
|
-
const apiSettings = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
296
|
-
apiSettings.use('*', async (c, next) => {
|
|
297
|
-
await authMiddleware(c.env.JWT_SECRET, {
|
|
298
|
-
issuer: c.env.JWT_ISSUER,
|
|
299
|
-
audience: c.env.JWT_AUDIENCE,
|
|
300
|
-
})(c, next)
|
|
301
|
-
})
|
|
302
|
-
apiSettings.route('/', settingsApp)
|
|
303
|
-
app.route('/api/settings', apiSettings)
|
|
304
|
-
|
|
305
|
-
// API Content: CRUD universale protetto da JWT
|
|
306
|
-
const apiContent = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
307
|
-
apiContent.use('*', async (c, next) => {
|
|
308
|
-
await authMiddleware(c.env.JWT_SECRET, {
|
|
309
|
-
issuer: c.env.JWT_ISSUER,
|
|
310
|
-
audience: c.env.JWT_AUDIENCE,
|
|
311
|
-
})(c, next)
|
|
312
|
-
})
|
|
313
|
-
// Specific routes before wildcard content routes to avoid pattern conflicts
|
|
314
|
-
apiContent.route('/', notificationsApp)
|
|
315
|
-
apiContent.route('/', statsApp)
|
|
316
|
-
apiContent.route('/', rotateFieldApp)
|
|
317
|
-
apiContent.route('/', draftApp)
|
|
318
|
-
apiContent.route('/', contentRoutes)
|
|
319
|
-
app.route('/api/content', apiContent)
|
|
320
|
-
app.route('/api/search', searchRouter)
|
|
321
|
-
|
|
322
|
-
// API Widget: endpoint aggregati per i widget della dashboard, protetti da JWT
|
|
323
|
-
const apiWidget = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
324
|
-
apiWidget.use('*', async (c, next) => {
|
|
325
|
-
await authMiddleware(c.env.JWT_SECRET, {
|
|
326
|
-
issuer: c.env.JWT_ISSUER,
|
|
327
|
-
audience: c.env.JWT_AUDIENCE,
|
|
328
|
-
})(c, next)
|
|
329
|
-
})
|
|
330
|
-
apiWidget.route('/', widgetApp)
|
|
331
|
-
app.route('/api/widget', apiWidget)
|
|
332
|
-
|
|
333
|
-
// API Upload: POST /api/upload (JWT) + GET /api/media/:key (pubblico)
|
|
334
|
-
app.route('/api', uploadRoutes)
|
|
335
|
-
app.get('/api/media/:key', (c) => serveMediaHandler(c))
|
|
20
|
+
const app = createBeechApp({ seeds })
|
|
336
21
|
|
|
337
|
-
|
|
338
|
-
const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
339
|
-
apiPublic.use('*', publicRateLimitMiddleware())
|
|
340
|
-
apiPublic.use('*', apiKeyMiddleware())
|
|
341
|
-
apiPublic.route('/', publicRoutes)
|
|
342
|
-
app.route('/api/v1/public', apiPublic)
|
|
22
|
+
app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
|
|
343
23
|
|
|
344
24
|
export default app
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import { D1ContentRepository } from '../shared/content.repository.d1'
|
|
3
|
+
import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
|
|
4
|
+
import type { ContentRepository, IdempotencyRepository } from '@beechcms/core'
|
|
5
|
+
import type { Env, Variables } from '../types'
|
|
6
|
+
|
|
7
|
+
interface RepositoryOverrides {
|
|
8
|
+
repository?: ContentRepository
|
|
9
|
+
idempotencyRepository?: IdempotencyRepository
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
|
|
13
|
+
return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
|
|
14
|
+
context.set('repository', overrides?.repository ?? new D1ContentRepository(context.env.DB))
|
|
15
|
+
context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(context.env.DB))
|
|
16
|
+
await next()
|
|
17
|
+
})
|
|
18
|
+
}
|
package/src/public/public-add.ts
CHANGED
|
@@ -1,30 +1,15 @@
|
|
|
1
|
-
import { isValidContentStatus } from '@beechcms/core'
|
|
2
|
-
import type { Seed } from '@beechcms/core'
|
|
1
|
+
import { isValidContentStatus, SlugConflictError } from '@beechcms/core'
|
|
3
2
|
import type { Context } from 'hono'
|
|
4
3
|
import { cleanStr } from '../shared/query-utils'
|
|
5
|
-
import { buildInsertBindings } from '../shared/content-utils'
|
|
6
4
|
import { checkPublicOperation } from './access-policy'
|
|
7
5
|
import { publicProblem } from './problem-details'
|
|
8
6
|
import { generateEntrySlug, slugify } from './slug-utils'
|
|
9
7
|
import { sanitizePublicPayload } from './sanitize'
|
|
10
8
|
import { createNotification } from '../shared/notification-service'
|
|
9
|
+
import { AppEnv } from '../types'
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
11
|
+
function errorMessage(context: Context<AppEnv>, error: unknown): string {
|
|
12
|
+
if (context.env.ENV !== 'production' && error instanceof Error) return error.message
|
|
28
13
|
return 'An unexpected error occurred.'
|
|
29
14
|
}
|
|
30
15
|
|
|
@@ -57,127 +42,125 @@ async function sha256Hex(input: string): Promise<string> {
|
|
|
57
42
|
return toHex(digest)
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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)
|
|
45
|
+
export async function publicAddHandler(context: Context<AppEnv>) {
|
|
46
|
+
const seedSlug = context.req.param('seed') ?? ''
|
|
47
|
+
const seed = context.get('getSeed')(seedSlug)
|
|
94
48
|
if (!seed) {
|
|
95
|
-
return publicProblem(
|
|
49
|
+
return publicProblem(context, {
|
|
50
|
+
type: 'seed-not-found',
|
|
51
|
+
title: 'Seed Not Found',
|
|
52
|
+
status: 404,
|
|
53
|
+
detail: `The content type '${seedSlug}' does not exist.`
|
|
54
|
+
})
|
|
96
55
|
}
|
|
56
|
+
|
|
97
57
|
const access = checkPublicOperation(seed, 'add')
|
|
98
58
|
if (!access.ok) {
|
|
99
|
-
return publicProblem(
|
|
59
|
+
return publicProblem(context, {
|
|
60
|
+
type: 'operation-not-allowed',
|
|
61
|
+
title: access.error.error,
|
|
62
|
+
status: 403,
|
|
63
|
+
detail: access.error.message
|
|
64
|
+
})
|
|
100
65
|
}
|
|
101
66
|
|
|
102
67
|
let body: Record<string, unknown>
|
|
103
68
|
try {
|
|
104
|
-
const parsed = await
|
|
69
|
+
const parsed = await context.req.json<unknown>()
|
|
105
70
|
body = asRecord(parsed) ?? {}
|
|
106
71
|
} catch {
|
|
107
|
-
return publicProblem(
|
|
72
|
+
return publicProblem(context, { type: 'invalid-json-body', title: 'Bad Request', status: 400, detail: 'Invalid JSON body' })
|
|
108
73
|
}
|
|
109
74
|
|
|
110
75
|
const rawData = asRecord(body.data)
|
|
111
76
|
if (!rawData || Object.keys(rawData).length === 0) {
|
|
112
|
-
return publicProblem(
|
|
77
|
+
return publicProblem(context, { type: 'invalid-data-object', title: 'Bad Request', status: 400, detail: "Field 'data' is required and must be a non-empty object" })
|
|
113
78
|
}
|
|
114
79
|
|
|
115
80
|
const statusValue = body.status ?? 'draft'
|
|
116
81
|
if (!isValidContentStatus(statusValue)) {
|
|
117
|
-
return publicProblem(
|
|
82
|
+
return publicProblem(context, { type: 'invalid-status', title: 'Bad Request', status: 400, detail: 'Invalid status. Allowed values are: draft, review, published' })
|
|
118
83
|
}
|
|
119
84
|
|
|
120
85
|
const sanitized = sanitizePublicPayload(seed, rawData, {
|
|
121
|
-
operation: 'create',
|
|
86
|
+
operation: 'create',
|
|
87
|
+
allowNull: false,
|
|
88
|
+
requireAtLeastOneValidField: true,
|
|
89
|
+
enforceRequiredFields: true,
|
|
122
90
|
})
|
|
91
|
+
|
|
123
92
|
if (!sanitized.ok) {
|
|
124
93
|
if (sanitized.status === 422) {
|
|
125
|
-
return publicProblem(
|
|
94
|
+
return publicProblem(context, { type: sanitized.code, title: 'Unprocessable Entity', status: 422, detail: sanitized.message })
|
|
126
95
|
}
|
|
127
|
-
return publicProblem(
|
|
96
|
+
return publicProblem(context, { type: sanitized.code, title: 'Bad Request', status: 400, detail: sanitized.message, errors: sanitized.details })
|
|
128
97
|
}
|
|
129
98
|
|
|
130
|
-
const idempotencyKey = parseIdempotencyKey(
|
|
99
|
+
const idempotencyKey = parseIdempotencyKey(context.req.header('Idempotency-Key'))
|
|
131
100
|
const entrySlug = pickSlugFromBody(body, sanitized.data)
|
|
132
101
|
const finalSlug = entrySlug || crypto.randomUUID().slice(0, 8)
|
|
133
|
-
const
|
|
102
|
+
const repository = context.get('repository')
|
|
103
|
+
const idempotencyRepository = context.get('idempotencyRepository')
|
|
134
104
|
|
|
135
105
|
try {
|
|
136
|
-
const { DB } = c.env
|
|
137
106
|
const now = Math.floor(Date.now() / 1000)
|
|
138
107
|
const fingerprintPayload = JSON.stringify({ seedSlug, statusValue, slug: cleanStr(body.slug) ?? null, data: sanitized.data })
|
|
139
108
|
const fingerprint = await sha256Hex(fingerprintPayload)
|
|
140
|
-
const idempotencyTtlSeconds = Math.max(60, Number.parseInt(
|
|
109
|
+
const idempotencyTtlSeconds = Math.max(60, Number.parseInt(context.env.PUBLIC_IDEMPOTENCY_TTL_SECONDS ?? '86400', 10) || 86400)
|
|
141
110
|
|
|
142
111
|
if (idempotencyKey) {
|
|
143
|
-
const existing = await
|
|
144
|
-
if (existing && existing.
|
|
145
|
-
if (existing.
|
|
146
|
-
return publicProblem(
|
|
112
|
+
const existing = await idempotencyRepository.lookup(idempotencyKey)
|
|
113
|
+
if (existing && existing.expiresAt >= now) {
|
|
114
|
+
if (existing.fingerprint !== fingerprint) {
|
|
115
|
+
return publicProblem(context, { type: 'idempotency-key-conflict', title: 'Conflict', status: 409, detail: 'Idempotency-Key was already used with a different request payload.' })
|
|
147
116
|
}
|
|
148
117
|
let parsedBody: unknown = null
|
|
149
|
-
try { parsedBody = JSON.parse(existing.
|
|
150
|
-
return
|
|
118
|
+
try { parsedBody = JSON.parse(existing.responseBody) } catch { parsedBody = { success: true } }
|
|
119
|
+
return context.json(parsedBody, existing.responseStatus as 201)
|
|
151
120
|
}
|
|
152
121
|
}
|
|
153
122
|
|
|
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
123
|
const id = crypto.randomUUID()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await repository.create(seed, id, finalSlug, statusValue as any, sanitized.data)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error instanceof SlugConflictError) {
|
|
129
|
+
return publicProblem(context, {
|
|
130
|
+
type: 'slug-conflict',
|
|
131
|
+
title: 'Conflict',
|
|
132
|
+
status: 409,
|
|
133
|
+
detail: `An entry with slug '${finalSlug}' already exists for content type '${seedSlug}'.`
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
throw error
|
|
137
|
+
}
|
|
167
138
|
|
|
168
139
|
const responseBody = { success: true, id, slug: finalSlug }
|
|
169
140
|
if (idempotencyKey) {
|
|
170
|
-
await
|
|
141
|
+
await idempotencyRepository.store({
|
|
142
|
+
key: idempotencyKey,
|
|
143
|
+
fingerprint,
|
|
144
|
+
responseStatus: 201,
|
|
145
|
+
responseBody: JSON.stringify(responseBody),
|
|
146
|
+
expiresAt: now + idempotencyTtlSeconds
|
|
147
|
+
})
|
|
171
148
|
}
|
|
172
149
|
|
|
173
|
-
await createNotification(
|
|
150
|
+
await createNotification(context, {
|
|
174
151
|
title: `${seed.label}: Nuovo inserimento`,
|
|
175
152
|
message: `Una nuova entry ("${sanitized.data.title || sanitized.data.name || finalSlug}") è stata aggiunta via API pubblica.`,
|
|
176
153
|
type: 'success',
|
|
177
154
|
})
|
|
178
155
|
|
|
179
|
-
return
|
|
180
|
-
} catch (
|
|
181
|
-
|
|
156
|
+
return context.json(responseBody, 201)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('Public add error:', error)
|
|
159
|
+
return publicProblem(context, {
|
|
160
|
+
type: 'internal-server-error',
|
|
161
|
+
title: 'Internal Server Error',
|
|
162
|
+
status: 500,
|
|
163
|
+
detail: errorMessage(context, error)
|
|
164
|
+
})
|
|
182
165
|
}
|
|
183
166
|
}
|