@beechcms/api 0.4.0-preview.2 → 0.4.0-preview.3
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/package.json +2 -2
- package/src/factory.ts +223 -4
- package/src/features/schema/schema.handler.ts +16 -0
- package/src/index.ts +6 -339
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@beechcms/api",
|
|
3
|
-
"version": "0.4.0-preview.
|
|
3
|
+
"version": "0.4.0-preview.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/factory.ts"
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@aws-sdk/client-s3": "^3.995.0",
|
|
24
|
-
"@beechcms/core": "^0.4.0-preview.
|
|
24
|
+
"@beechcms/core": "^0.4.0-preview.3",
|
|
25
25
|
"bcryptjs": "^2.4.3",
|
|
26
26
|
"hono": "^4.11.9",
|
|
27
27
|
"jose": "^6.1.3"
|
package/src/factory.ts
CHANGED
|
@@ -1,16 +1,91 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
3
|
import { cors } from 'hono/cors'
|
|
4
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
4
5
|
import type { Seed } from '@beechcms/core'
|
|
5
6
|
import type { Env, Variables } from './types'
|
|
6
7
|
|
|
8
|
+
// Imports delle rotte e middleware
|
|
9
|
+
import { AUTH_ERRORS } from './auth/constants'
|
|
10
|
+
import {
|
|
11
|
+
parseLoginBody,
|
|
12
|
+
validateLoginInput,
|
|
13
|
+
findUserByEmail,
|
|
14
|
+
verifyPassword,
|
|
15
|
+
DUMMY_PASSWORD_HASH,
|
|
16
|
+
} from './auth/login'
|
|
17
|
+
import {
|
|
18
|
+
generateRefreshToken,
|
|
19
|
+
saveRefreshToken,
|
|
20
|
+
generateAccessToken,
|
|
21
|
+
validateRefreshToken,
|
|
22
|
+
revokeRefreshToken,
|
|
23
|
+
} from './auth/refresh'
|
|
24
|
+
import { authMiddleware } from './middleware'
|
|
25
|
+
import { contentRoutes } from './content'
|
|
26
|
+
import { widgetApp } from './widget'
|
|
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 { schemaApp } from './features/schema/schema.handler'
|
|
33
|
+
import { notificationsApp } from './features/notifications'
|
|
34
|
+
import { statsApp } from './features/stats'
|
|
35
|
+
import { uploadRoutes, serveMediaHandler } from './upload'
|
|
36
|
+
import { publicRoutes, apiKeyMiddleware, publicRateLimitMiddleware } from './public'
|
|
37
|
+
import { searchRouter } from "./search"
|
|
38
|
+
|
|
7
39
|
export interface BeechConfig {
|
|
8
40
|
seeds: Seed[]
|
|
9
41
|
}
|
|
10
42
|
|
|
43
|
+
// --- Costanti e helper ---
|
|
44
|
+
const REFRESH_TOKEN_EXPIRY_DAYS = 7
|
|
45
|
+
const SECONDS_PER_DAY = 24 * 60 * 60
|
|
46
|
+
|
|
47
|
+
function isRequestSecure(url: string): boolean {
|
|
48
|
+
return new URL(url).protocol === 'https:'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getClientIp(headers: Headers): string {
|
|
52
|
+
return headers.get('cf-connecting-ip') ?? 'unknown'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getRefreshTokenCookieOptions(secure: boolean) {
|
|
56
|
+
return {
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
secure,
|
|
59
|
+
sameSite: 'Strict' as const,
|
|
60
|
+
maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
|
|
61
|
+
path: '/auth',
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getRefreshTokenDeleteCookieOptions(secure: boolean) {
|
|
66
|
+
return {
|
|
67
|
+
httpOnly: true,
|
|
68
|
+
secure,
|
|
69
|
+
sameSite: 'Strict' as const,
|
|
70
|
+
path: '/auth',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function handleAuthError(c: any, err: unknown, operationName: string): Response {
|
|
75
|
+
if (c.env.ENV !== 'production') {
|
|
76
|
+
console.error(`${operationName} error:`, err)
|
|
77
|
+
}
|
|
78
|
+
return c.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function extractPublicSeed(path: string): string {
|
|
82
|
+
const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
|
|
83
|
+
return match ? match[1] : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
11
86
|
/**
|
|
12
87
|
* Builds a fully configured Hono app with the given seeds injected into context.
|
|
13
|
-
*
|
|
88
|
+
* This is the main entry point for a BeechCMS project.
|
|
14
89
|
*/
|
|
15
90
|
export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
|
|
16
91
|
const registry: Record<string, Seed> = Object.fromEntries(config.seeds.map(s => [s.slug, s]))
|
|
@@ -18,14 +93,13 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
18
93
|
|
|
19
94
|
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
20
95
|
|
|
21
|
-
//
|
|
96
|
+
// 1. Core Middleware (Seeds, CORS, Security)
|
|
22
97
|
app.use('*', async (c, next) => {
|
|
23
98
|
c.set('getSeed', getSeedFn)
|
|
24
99
|
c.set('seedRegistry', registry)
|
|
25
100
|
await next()
|
|
26
101
|
})
|
|
27
102
|
|
|
28
|
-
// CORS
|
|
29
103
|
app.use('*', async (c, next) => {
|
|
30
104
|
const origins = (c.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
|
31
105
|
.split(',')
|
|
@@ -42,7 +116,6 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
42
116
|
})(c, next)
|
|
43
117
|
})
|
|
44
118
|
|
|
45
|
-
// Security headers
|
|
46
119
|
app.use('*', async (c, next) => {
|
|
47
120
|
await next()
|
|
48
121
|
c.header('X-Frame-Options', 'DENY')
|
|
@@ -52,5 +125,151 @@ export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Varia
|
|
|
52
125
|
c.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
|
|
53
126
|
})
|
|
54
127
|
|
|
128
|
+
// 2. Analytics Middleware
|
|
129
|
+
app.use('/api/*', async (c, next) => {
|
|
130
|
+
await next()
|
|
131
|
+
if (c.req.method !== 'OPTIONS' && c.res.status >= 200 && c.res.status < 300) {
|
|
132
|
+
const db = c.env.DB
|
|
133
|
+
let executionCtx: any
|
|
134
|
+
try { executionCtx = c.executionCtx } catch {}
|
|
135
|
+
|
|
136
|
+
if (db && executionCtx) {
|
|
137
|
+
const seed = extractPublicSeed(c.req.path)
|
|
138
|
+
executionCtx.waitUntil((async () => {
|
|
139
|
+
try {
|
|
140
|
+
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
|
|
141
|
+
await db.prepare(
|
|
142
|
+
`INSERT INTO analytics (day_ts, metric, seed, value)
|
|
143
|
+
VALUES (?, 'requests', ?, 1)
|
|
144
|
+
ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
|
|
145
|
+
).bind(today, seed).run()
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('Analytics middleware error:', err)
|
|
148
|
+
}
|
|
149
|
+
})())
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// 3. Auth Routes
|
|
155
|
+
app.post('/auth/login', async (c) => {
|
|
156
|
+
try {
|
|
157
|
+
let body: any
|
|
158
|
+
try { body = await c.req.json() } catch { return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
|
|
159
|
+
const credentials = parseLoginBody(body)
|
|
160
|
+
if (!credentials) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
161
|
+
const { email, password } = credentials
|
|
162
|
+
if (!validateLoginInput(email, password)) return c.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
163
|
+
|
|
164
|
+
const loginLimiter = c.env.LOGIN_RATE_LIMITER
|
|
165
|
+
if (loginLimiter) {
|
|
166
|
+
const clientIp = getClientIp(c.req.raw.headers)
|
|
167
|
+
const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
|
|
168
|
+
if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { DB, JWT_SECRET } = c.env
|
|
172
|
+
const user = await findUserByEmail(DB, email)
|
|
173
|
+
const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
|
|
174
|
+
const isValid = await verifyPassword(password, hashToCompare)
|
|
175
|
+
|
|
176
|
+
if (!user || !isValid) return c.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
|
|
177
|
+
|
|
178
|
+
const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
|
|
179
|
+
const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
180
|
+
issuer: c.env.JWT_ISSUER,
|
|
181
|
+
audience: c.env.JWT_AUDIENCE,
|
|
182
|
+
}, userProfile?.name ?? undefined)
|
|
183
|
+
const refreshToken = generateRefreshToken()
|
|
184
|
+
|
|
185
|
+
await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
186
|
+
setCookie(c, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
|
|
187
|
+
return c.json({ token: accessToken, expiresIn: '15m' }, 200)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
return handleAuthError(c, err, 'Login')
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
app.post('/auth/refresh', async (c) => {
|
|
194
|
+
try {
|
|
195
|
+
const refreshLimiter = c.env.REFRESH_RATE_LIMITER
|
|
196
|
+
if (refreshLimiter) {
|
|
197
|
+
const clientIp = getClientIp(c.req.raw.headers)
|
|
198
|
+
const { success } = await refreshLimiter.limit({ key: clientIp })
|
|
199
|
+
if (!success) return c.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const refreshToken = getCookie(c, 'refresh_token')
|
|
203
|
+
if (!refreshToken) return c.json({ error: 'Refresh token missing' }, 401)
|
|
204
|
+
|
|
205
|
+
const { DB, JWT_SECRET } = c.env
|
|
206
|
+
const validation = await validateRefreshToken(DB, refreshToken)
|
|
207
|
+
if (!validation.valid || !validation.userId) return c.json({ error: 'Invalid refresh token' }, 401)
|
|
208
|
+
|
|
209
|
+
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 }>()
|
|
210
|
+
if (!user) return c.json({ error: 'User not found' }, 401)
|
|
211
|
+
|
|
212
|
+
const revoked = await revokeRefreshToken(DB, refreshToken)
|
|
213
|
+
if (!revoked) return c.json({ error: 'Invalid refresh token' }, 401)
|
|
214
|
+
|
|
215
|
+
const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
216
|
+
issuer: c.env.JWT_ISSUER,
|
|
217
|
+
audience: c.env.JWT_AUDIENCE,
|
|
218
|
+
}, user.name ?? undefined)
|
|
219
|
+
const newRefreshToken = generateRefreshToken()
|
|
220
|
+
|
|
221
|
+
await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
222
|
+
setCookie(c, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(c.req.url)))
|
|
223
|
+
return c.json({ token: newAccessToken, expiresIn: '15m' }, 200)
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return handleAuthError(c, err, 'Refresh')
|
|
226
|
+
}
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
app.post('/auth/logout', async (c) => {
|
|
230
|
+
try {
|
|
231
|
+
const refreshToken = getCookie(c, 'refresh_token')
|
|
232
|
+
if (refreshToken) await revokeRefreshToken(c.env.DB, refreshToken)
|
|
233
|
+
deleteCookie(c, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(c.req.url)))
|
|
234
|
+
return c.json({ message: 'Logged out' }, 200)
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return handleAuthError(c, err, 'Logout')
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
// 4. Setup & Password Reset
|
|
241
|
+
app.route('/', setupApp)
|
|
242
|
+
app.route('/', passwordResetApp)
|
|
243
|
+
|
|
244
|
+
// 5. Protected CMS API
|
|
245
|
+
const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
246
|
+
apiProtected.use('*', async (c, next) => {
|
|
247
|
+
await authMiddleware(c.env.JWT_SECRET, {
|
|
248
|
+
issuer: c.env.JWT_ISSUER,
|
|
249
|
+
audience: c.env.JWT_AUDIENCE,
|
|
250
|
+
})(c, next)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
apiProtected.route('/settings', settingsApp)
|
|
254
|
+
apiProtected.route('/schema', schemaApp)
|
|
255
|
+
apiProtected.route('/content', notificationsApp)
|
|
256
|
+
apiProtected.route('/content', statsApp)
|
|
257
|
+
apiProtected.route('/content', rotateFieldApp)
|
|
258
|
+
apiProtected.route('/content', draftApp)
|
|
259
|
+
apiProtected.route('/content', contentRoutes)
|
|
260
|
+
apiProtected.route('/widget', widgetApp)
|
|
261
|
+
|
|
262
|
+
app.route('/api', apiProtected)
|
|
263
|
+
app.route('/api/search', searchRouter)
|
|
264
|
+
app.route('/api', uploadRoutes)
|
|
265
|
+
app.get('/api/media/:key', (c) => serveMediaHandler(c))
|
|
266
|
+
|
|
267
|
+
// 6. Public API
|
|
268
|
+
const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
269
|
+
apiPublic.use('*', publicRateLimitMiddleware())
|
|
270
|
+
apiPublic.use('*', apiKeyMiddleware())
|
|
271
|
+
apiPublic.route('/', publicRoutes)
|
|
272
|
+
app.route('/api/v1/public', apiPublic)
|
|
273
|
+
|
|
55
274
|
return app
|
|
56
275
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import type { Env, Variables } from '../../types'
|
|
4
|
+
|
|
5
|
+
const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ritorna l'intero schema del CMS (la lista dei Seed configurati).
|
|
9
|
+
* Usato dalla Dashboard per generare dinamicamente il menu e le form.
|
|
10
|
+
*/
|
|
11
|
+
schemaApp.get('/', async (c) => {
|
|
12
|
+
const registry = c.get('seedRegistry')
|
|
13
|
+
return c.json(Object.values(registry))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export { schemaApp }
|
package/src/index.ts
CHANGED
|
@@ -1,344 +1,11 @@
|
|
|
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
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Entry point per lo sviluppo locale del monorepo.
|
|
5
|
+
* In produzione (progetto utente), viene usato worker.ts che importa createBeechApp.
|
|
6
|
+
*/
|
|
7
|
+
const app = createBeechApp({ seeds: [] })
|
|
40
8
|
|
|
41
|
-
|
|
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)
|
|
9
|
+
app.get('/', (c) => c.text('Beech API is running (Production Ready Mode)'))
|
|
343
10
|
|
|
344
11
|
export default app
|