@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
package/src/index.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
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
|
+
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
|
+
|
|
39
|
+
// --- Costanti e helper ---
|
|
40
|
+
|
|
41
|
+
/** Giorni di validità del refresh token */
|
|
42
|
+
const REFRESH_TOKEN_EXPIRY_DAYS = 7
|
|
43
|
+
|
|
44
|
+
/** Secondi in un giorno (per maxAge cookie) */
|
|
45
|
+
const SECONDS_PER_DAY = 24 * 60 * 60
|
|
46
|
+
|
|
47
|
+
/** Restituisce true se la richiesta è su HTTPS */
|
|
48
|
+
function isRequestSecure(url: string): boolean {
|
|
49
|
+
return new URL(url).protocol === 'https:'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Estrae l'IP del client (header Cloudflare) o 'unknown' se non disponibile */
|
|
53
|
+
function getClientIp(headers: Headers): string {
|
|
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] : ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Middleware Analytics: traccia le richieste per la dashboard (Cloudflare-style metrics)
|
|
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))
|
|
336
|
+
|
|
337
|
+
// API Pubblica: endpoint per consumatori esterni, protetti da API Key
|
|
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)
|
|
343
|
+
|
|
344
|
+
export default app
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media utils: estrazione chiavi R2 dal data di un'entry.
|
|
3
|
+
* Usato alla cancellazione entry per eliminare i file associati da R2.
|
|
4
|
+
*
|
|
5
|
+
* @see docs/media-engine.md
|
|
6
|
+
*/
|
|
7
|
+
import type { Seed } from '@beechcms/core'
|
|
8
|
+
|
|
9
|
+
/** Pattern per estrarre la chiave R2 da URL in formato /api/media/KEY */
|
|
10
|
+
const MEDIA_URL_PATTERN = /\/api\/media\/([^/?#]+)/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Estrae la chiave R2 da un URL di media.
|
|
14
|
+
* Es: "https://x.com/api/media/1739123456-avatar.png" → "1739123456-avatar.png"
|
|
15
|
+
*
|
|
16
|
+
* @param mediaUrl - URL completo o path (es. /api/media/123-foto.png)
|
|
17
|
+
* @returns Chiave R2 o null se l'URL non è valido
|
|
18
|
+
*/
|
|
19
|
+
export function extractMediaKey(mediaUrl: string): string | null {
|
|
20
|
+
const match = MEDIA_URL_PATTERN.exec(String(mediaUrl))
|
|
21
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Attraversa ricorsivamente un valore (stringa, array, oggetto) e raccoglie
|
|
26
|
+
* tutte le chiavi R2 trovate in stringhe che matchano /api/media/KEY.
|
|
27
|
+
*/
|
|
28
|
+
function collectMediaKeysRecursive(value: unknown, collectedKeys: Set<string>): void {
|
|
29
|
+
if (typeof value === 'string') {
|
|
30
|
+
const r2Key = extractMediaKey(value)
|
|
31
|
+
if (r2Key) {
|
|
32
|
+
collectedKeys.add(r2Key)
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
// Legacy compat: campi json/file possono contenere JSON serializzato.
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(value) as unknown
|
|
38
|
+
if (parsed !== value) {
|
|
39
|
+
collectMediaKeysRecursive(parsed, collectedKeys)
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
for (const item of value) collectMediaKeysRecursive(item, collectedKeys)
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
if (value != null && typeof value === 'object') {
|
|
51
|
+
for (const nestedValue of Object.values(value)) {
|
|
52
|
+
collectMediaKeysRecursive(nestedValue, collectedKeys)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Estrae tutte le chiavi R2 dal data di un'entry.
|
|
59
|
+
* Cerca nei campi `file` (stringa URL) e `json` (array/oggetto con URL).
|
|
60
|
+
* Il data è in formato DB: chiavi = branch.id (es. art_03, prd_05).
|
|
61
|
+
*
|
|
62
|
+
* @param seed - Schema del tipo di contenuto
|
|
63
|
+
* @param entryData - Payload in formato DB (chiavi = branch ID)
|
|
64
|
+
* @returns Array di chiavi R2 uniche da eliminare
|
|
65
|
+
*/
|
|
66
|
+
export function extractMediaKeysFromData(
|
|
67
|
+
seed: Seed,
|
|
68
|
+
entryData: Record<string, unknown>
|
|
69
|
+
): string[] {
|
|
70
|
+
const r2Keys = new Set<string>()
|
|
71
|
+
for (const branch of seed.branches) {
|
|
72
|
+
if (branch.type !== 'file' && branch.type !== 'json') continue
|
|
73
|
+
const fieldValue = entryData[branch.alias]
|
|
74
|
+
if (fieldValue == null) continue
|
|
75
|
+
collectMediaKeysRecursive(fieldValue, r2Keys)
|
|
76
|
+
}
|
|
77
|
+
return [...r2Keys]
|
|
78
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { Context, Next } from 'hono'
|
|
3
|
+
import { HTTPException } from 'hono/http-exception'
|
|
4
|
+
import { jwtVerify } from 'jose'
|
|
5
|
+
|
|
6
|
+
/** Payload JWT decodificato (sub = userId, email opzionale, name opzionale) */
|
|
7
|
+
export type JwtPayload = {
|
|
8
|
+
sub: string
|
|
9
|
+
email?: string
|
|
10
|
+
name?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Variabili iniettate nel context Hono dopo auth */
|
|
14
|
+
export type AuthVariables = {
|
|
15
|
+
jwtPayload: JwtPayload
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
|
|
19
|
+
|
|
20
|
+
function unauthorizedResponse() {
|
|
21
|
+
return new Response(UNAUTHORIZED_JSON, {
|
|
22
|
+
status: 401,
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type JwtVerifyOptions = {
|
|
28
|
+
issuer?: string
|
|
29
|
+
audience?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Middleware di autenticazione JWT.
|
|
34
|
+
* Intercetta Authorization: Bearer <token>, verifica con jose e JWT_SECRET.
|
|
35
|
+
* Se valido: imposta jwtPayload nel context e chiama next().
|
|
36
|
+
* Se invalido/mancante: lancia HTTPException 401 (gestita dal framework).
|
|
37
|
+
*/
|
|
38
|
+
export function authMiddleware(secret: string, options: JwtVerifyOptions = {}) {
|
|
39
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
40
|
+
const auth = c.req.header('Authorization')
|
|
41
|
+
if (!auth?.startsWith('Bearer ')) {
|
|
42
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const token = auth.slice(7)
|
|
46
|
+
if (!token) {
|
|
47
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const secretBytes = new TextEncoder().encode(secret)
|
|
52
|
+
const { payload, protectedHeader } = await jwtVerify(token, secretBytes, {
|
|
53
|
+
algorithms: ['HS256'],
|
|
54
|
+
issuer: options.issuer,
|
|
55
|
+
audience: options.audience,
|
|
56
|
+
})
|
|
57
|
+
// Hardening: accetta solo token JWT standard (se presente il typ)
|
|
58
|
+
if (protectedHeader.typ && protectedHeader.typ !== 'JWT') {
|
|
59
|
+
throw new Error('Invalid typ header')
|
|
60
|
+
}
|
|
61
|
+
c.set('jwtPayload', payload as JwtPayload)
|
|
62
|
+
await next()
|
|
63
|
+
} catch {
|
|
64
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Seed } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
export type PublicOperation = 'read' | 'add' | 'edit'
|
|
4
|
+
|
|
5
|
+
function isAllowed(seed: Seed, operation: PublicOperation): boolean {
|
|
6
|
+
if (operation === 'read') return seed.allowPublicRead === true
|
|
7
|
+
if (operation === 'add') return seed.allowPublicPost === true
|
|
8
|
+
return seed.allowPublicEdit === true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function checkPublicOperation(seed: Seed, operation: PublicOperation) {
|
|
12
|
+
if (isAllowed(seed, operation)) {
|
|
13
|
+
return { ok: true } as const
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
error: {
|
|
19
|
+
error: 'Forbidden',
|
|
20
|
+
message: `Public ${operation.toUpperCase()} is not allowed for content type '${seed.slug}'.`,
|
|
21
|
+
},
|
|
22
|
+
} as const
|
|
23
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Context, Next } from 'hono'
|
|
2
|
+
import { PUBLIC_ERRORS } from './public-errors'
|
|
3
|
+
import { publicProblem } from './problem-details'
|
|
4
|
+
|
|
5
|
+
type PublicBindings = {
|
|
6
|
+
PUBLIC_READ_API_KEY?: string
|
|
7
|
+
PUBLIC_WRITE_API_KEY?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function isReadMethod(method: string): boolean {
|
|
11
|
+
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getConfiguredKey(env: PublicBindings, method: string): string | undefined {
|
|
15
|
+
if (isReadMethod(method)) {
|
|
16
|
+
return env.PUBLIC_READ_API_KEY
|
|
17
|
+
}
|
|
18
|
+
return env.PUBLIC_WRITE_API_KEY
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* API key auth middleware for Public API routes.
|
|
23
|
+
* Uses X-API-Key header only.
|
|
24
|
+
*/
|
|
25
|
+
export function apiKeyMiddleware() {
|
|
26
|
+
return async (c: Context, next: Next): Promise<Response | void> => {
|
|
27
|
+
const env = c.env as PublicBindings
|
|
28
|
+
const configuredKey = getConfiguredKey(env, c.req.method)
|
|
29
|
+
|
|
30
|
+
if (!configuredKey) {
|
|
31
|
+
return publicProblem(c, {
|
|
32
|
+
type: 'public-api-not-configured',
|
|
33
|
+
title: PUBLIC_ERRORS.API_KEY_FORBIDDEN.error,
|
|
34
|
+
status: 403,
|
|
35
|
+
detail: PUBLIC_ERRORS.API_KEY_FORBIDDEN.message,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const providedKey = c.req.header('X-API-Key')
|
|
40
|
+
|
|
41
|
+
if (!providedKey || providedKey !== configuredKey) {
|
|
42
|
+
return publicProblem(c, {
|
|
43
|
+
type: 'public-api-key-unauthorized',
|
|
44
|
+
title: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.error,
|
|
45
|
+
status: 401,
|
|
46
|
+
detail: PUBLIC_ERRORS.API_KEY_UNAUTHORIZED.message,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await next()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { apiKeyMiddleware } from './api-key-middleware'
|
|
2
|
+
export { publicRateLimitMiddleware } from './rate-limit-middleware'
|
|
3
|
+
export { publicRoutes } from './public-routes'
|
|
4
|
+
export { PUBLIC_ERRORS } from './public-errors'
|
|
5
|
+
export { sanitizePublicPayload } from './sanitize'
|
|
6
|
+
export { generateEntrySlug, slugify } from './slug-utils'
|
|
7
|
+
export { buildPublicListMeta, buildPublicSingleMeta } from './response-builder'
|
|
8
|
+
export { parseLatestCount, parsePublicPagination } from './query-builder'
|
|
9
|
+
export { publicReadHandler } from './public-read'
|
|
10
|
+
export { publicAddHandler } from './public-add'
|
|
11
|
+
export { publicEditHandler } from './public-edit'
|
|
12
|
+
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export interface PublicProblemDetailItem {
|
|
4
|
+
field: string
|
|
5
|
+
expected: string
|
|
6
|
+
received: string
|
|
7
|
+
message: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type PublicProblemInput = {
|
|
11
|
+
type: string
|
|
12
|
+
title: string
|
|
13
|
+
status: 400 | 401 | 403 | 404 | 405 | 409 | 422 | 429 | 500 | 501
|
|
14
|
+
detail: string
|
|
15
|
+
errors?: PublicProblemDetailItem[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeProblemType(type: string): string {
|
|
19
|
+
if (type.startsWith('http://') || type.startsWith('https://')) {
|
|
20
|
+
return type
|
|
21
|
+
}
|
|
22
|
+
return `https://beechcms.dev/problems/${type}`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Restituisce errori API in formato Problem Details (RFC 9457).
|
|
27
|
+
*/
|
|
28
|
+
export function publicProblem(c: Context, input: PublicProblemInput): Response {
|
|
29
|
+
const body: Record<string, unknown> = {
|
|
30
|
+
type: normalizeProblemType(input.type),
|
|
31
|
+
title: input.title,
|
|
32
|
+
status: input.status,
|
|
33
|
+
detail: input.detail,
|
|
34
|
+
instance: c.req.path,
|
|
35
|
+
}
|
|
36
|
+
if (input.errors && input.errors.length > 0) {
|
|
37
|
+
body.errors = input.errors
|
|
38
|
+
}
|
|
39
|
+
return c.json(body, input.status, {
|
|
40
|
+
'Content-Type': 'application/problem+json',
|
|
41
|
+
})
|
|
42
|
+
}
|