@beechcms/api 0.4.0-preview.8 → 0.4.0
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 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -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 +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -249
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.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
|
@@ -1,303 +1,347 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { Hono } from 'hono'
|
|
3
|
-
import { cors } from 'hono/cors'
|
|
4
|
-
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
5
|
-
import type { Seed } from '@beechcms/core'
|
|
6
|
-
import type { Env, Variables } from './types'
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
app
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
return
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { cors } from 'hono/cors'
|
|
4
|
+
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
|
|
5
|
+
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
|
|
6
|
+
import type { Env, Variables } from './types'
|
|
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 contentFeature from './features/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
|
+
import { repositoryMiddleware } from './middleware/repository.middleware'
|
|
39
|
+
import { storageMiddleware } from './middleware/storage.middleware'
|
|
40
|
+
|
|
41
|
+
export interface BeechConfig {
|
|
42
|
+
seeds: Seed[] | Record<string, Seed>
|
|
43
|
+
repository?: ContentRepository
|
|
44
|
+
idempotencyRepository?: IdempotencyRepository
|
|
45
|
+
bucket?: BeechBucket
|
|
46
|
+
mediaRepository?: MediaRepository
|
|
47
|
+
systemStatsRepository?: SystemStatsRepository
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Costanti e helper ---
|
|
51
|
+
const REFRESH_TOKEN_EXPIRY_DAYS = 7
|
|
52
|
+
const SECONDS_PER_DAY = 24 * 60 * 60
|
|
53
|
+
|
|
54
|
+
function isRequestSecure(url: string): boolean {
|
|
55
|
+
return new URL(url).protocol === 'https:'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getClientIp(headers: Headers): string {
|
|
59
|
+
return headers.get('cf-connecting-ip') ?? 'unknown'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getRefreshTokenCookieOptions(secure: boolean) {
|
|
63
|
+
return {
|
|
64
|
+
httpOnly: true,
|
|
65
|
+
secure,
|
|
66
|
+
sameSite: 'Strict' as const,
|
|
67
|
+
maxAge: REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY,
|
|
68
|
+
path: '/auth',
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function getRefreshTokenDeleteCookieOptions(secure: boolean) {
|
|
73
|
+
return {
|
|
74
|
+
httpOnly: true,
|
|
75
|
+
secure,
|
|
76
|
+
sameSite: 'Strict' as const,
|
|
77
|
+
path: '/auth',
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function handleAuthError(context: any, error: unknown, operationName: string): Response {
|
|
82
|
+
if (context.env.ENV !== 'production') {
|
|
83
|
+
console.error(`${operationName} error:`, error)
|
|
84
|
+
}
|
|
85
|
+
return context.json({ error: AUTH_ERRORS.GENERIC_ERROR }, 500)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function extractPublicSeed(path: string): string {
|
|
89
|
+
const match = path.match(/^\/api\/v1\/public\/([^/?]+)/)
|
|
90
|
+
return match ? match[1] : ''
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds a fully configured Hono app with the given seeds injected into context.
|
|
95
|
+
* This is the main entry point for a BeechCMS project.
|
|
96
|
+
*/
|
|
97
|
+
export function createBeechApp(config: BeechConfig): Hono<{ Bindings: Env; Variables: Variables }> {
|
|
98
|
+
const seedsArray = Array.isArray(config.seeds) ? config.seeds : Object.values(config.seeds)
|
|
99
|
+
// Filter out any invalid objects that might have leaked into the registry (e.g. module exports)
|
|
100
|
+
const validSeeds = seedsArray.filter(s => s && typeof s === 'object' && 'slug' in s)
|
|
101
|
+
const registry: Record<string, Seed> = Object.fromEntries(validSeeds.map(s => [s.slug, s]))
|
|
102
|
+
const getSeedFn = (slug: string): Seed | null => registry[slug] ?? null
|
|
103
|
+
|
|
104
|
+
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
105
|
+
|
|
106
|
+
// 1. Core Middleware (Seeds, CORS, Security)
|
|
107
|
+
app.use('*', async (context, next) => {
|
|
108
|
+
context.set('getSeed', getSeedFn)
|
|
109
|
+
context.set('seedRegistry', registry)
|
|
110
|
+
await next()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// 1.1 Repository Injection
|
|
114
|
+
app.use('*', repositoryMiddleware({
|
|
115
|
+
repository: config.repository,
|
|
116
|
+
idempotencyRepository: config.idempotencyRepository,
|
|
117
|
+
mediaRepository: config.mediaRepository,
|
|
118
|
+
systemStatsRepository: config.systemStatsRepository,
|
|
119
|
+
}))
|
|
120
|
+
|
|
121
|
+
app.use('*', storageMiddleware({
|
|
122
|
+
bucket: config.bucket,
|
|
123
|
+
}))
|
|
124
|
+
|
|
125
|
+
app.use('*', async (context, next) => {
|
|
126
|
+
const isDev = context.env.ENV !== 'production'
|
|
127
|
+
|
|
128
|
+
return cors({
|
|
129
|
+
origin: (origin) => {
|
|
130
|
+
if (!origin) return origin ?? ''
|
|
131
|
+
|
|
132
|
+
// In dev, allow all localhost/127.0.0.1 origins regardless of port
|
|
133
|
+
if (isDev) {
|
|
134
|
+
try {
|
|
135
|
+
const { hostname } = new URL(origin)
|
|
136
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') return origin
|
|
137
|
+
} catch {}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const origins = (context.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
|
141
|
+
.split(',')
|
|
142
|
+
.map((o) => o.trim())
|
|
143
|
+
.filter(Boolean)
|
|
144
|
+
|
|
145
|
+
if (origins.includes(origin)) return origin
|
|
146
|
+
|
|
147
|
+
// Allow same-origin requests
|
|
148
|
+
try {
|
|
149
|
+
const originUrl = new URL(origin)
|
|
150
|
+
const requestUrl = new URL(context.req.url)
|
|
151
|
+
if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
152
|
+
return origin
|
|
153
|
+
}
|
|
154
|
+
} catch {}
|
|
155
|
+
|
|
156
|
+
return null
|
|
157
|
+
},
|
|
158
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
159
|
+
allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
|
|
160
|
+
credentials: true,
|
|
161
|
+
})(context, next)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
app.use('*', async (context, next) => {
|
|
165
|
+
await next()
|
|
166
|
+
if (context.req.path.startsWith('/admin')) return
|
|
167
|
+
context.header('X-Frame-Options', 'DENY')
|
|
168
|
+
context.header('X-Content-Type-Options', 'nosniff')
|
|
169
|
+
context.header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
170
|
+
context.header('Permissions-Policy', 'geolocation=(), microphone=(), camera=()')
|
|
171
|
+
context.header('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'")
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// 2. Analytics Middleware
|
|
175
|
+
app.use('/api/*', async (context, next) => {
|
|
176
|
+
await next()
|
|
177
|
+
if (context.req.method !== 'OPTIONS' && context.res.status >= 200 && context.res.status < 300) {
|
|
178
|
+
const db = context.env.DB
|
|
179
|
+
let executionCtx: any
|
|
180
|
+
try { executionCtx = context.executionCtx } catch {}
|
|
181
|
+
|
|
182
|
+
if (db && executionCtx) {
|
|
183
|
+
const seed = extractPublicSeed(context.req.path)
|
|
184
|
+
executionCtx.waitUntil((async () => {
|
|
185
|
+
try {
|
|
186
|
+
const today = Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)
|
|
187
|
+
await db.prepare(
|
|
188
|
+
`INSERT INTO analytics (day_ts, metric, seed, value)
|
|
189
|
+
VALUES (?, 'requests', ?, 1)
|
|
190
|
+
ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`
|
|
191
|
+
).bind(today, seed).run()
|
|
192
|
+
} catch (error) {
|
|
193
|
+
console.error('Analytics middleware error:', error)
|
|
194
|
+
}
|
|
195
|
+
})())
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// 3. Auth Routes
|
|
201
|
+
app.post('/auth/login', async (context) => {
|
|
202
|
+
try {
|
|
203
|
+
let body: any
|
|
204
|
+
try { body = await context.req.json() } catch { return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400) }
|
|
205
|
+
const credentials = parseLoginBody(body)
|
|
206
|
+
if (!credentials) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
207
|
+
const { email, password } = credentials
|
|
208
|
+
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400)
|
|
209
|
+
|
|
210
|
+
const loginLimiter = context.env.LOGIN_RATE_LIMITER
|
|
211
|
+
if (loginLimiter) {
|
|
212
|
+
const clientIp = getClientIp(context.req.raw.headers)
|
|
213
|
+
const { success } = await loginLimiter.limit({ key: `${clientIp}:${email}` })
|
|
214
|
+
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { DB, JWT_SECRET } = context.env
|
|
218
|
+
const user = await findUserByEmail(DB, email)
|
|
219
|
+
const hashToCompare = user?.password_hash ?? DUMMY_PASSWORD_HASH
|
|
220
|
+
const isValid = await verifyPassword(password, hashToCompare)
|
|
221
|
+
|
|
222
|
+
if (!user || !isValid) return context.json({ error: AUTH_ERRORS.INVALID_CREDENTIALS }, 401)
|
|
223
|
+
|
|
224
|
+
const userProfile = await DB.prepare('SELECT name FROM users WHERE id = ? LIMIT 1').bind(user.id).first<{ name: string | null }>()
|
|
225
|
+
const accessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
226
|
+
issuer: context.env.JWT_ISSUER,
|
|
227
|
+
audience: context.env.JWT_AUDIENCE,
|
|
228
|
+
}, userProfile?.name ?? undefined)
|
|
229
|
+
const refreshToken = generateRefreshToken()
|
|
230
|
+
|
|
231
|
+
await saveRefreshToken(DB, user.id, refreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
232
|
+
setCookie(context, 'refresh_token', refreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
233
|
+
return context.json({ token: accessToken, expiresIn: '15m' }, 200)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return handleAuthError(context, error, 'Login')
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
app.post('/auth/refresh', async (context) => {
|
|
240
|
+
try {
|
|
241
|
+
const refreshLimiter = context.env.REFRESH_RATE_LIMITER
|
|
242
|
+
if (refreshLimiter) {
|
|
243
|
+
const clientIp = getClientIp(context.req.raw.headers)
|
|
244
|
+
const { success } = await refreshLimiter.limit({ key: clientIp })
|
|
245
|
+
if (!success) return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const refreshToken = getCookie(context, 'refresh_token')
|
|
249
|
+
if (!refreshToken) return context.json({ error: 'Refresh token missing' }, 401)
|
|
250
|
+
|
|
251
|
+
const { DB, JWT_SECRET } = context.env
|
|
252
|
+
const validation = await validateRefreshToken(DB, refreshToken)
|
|
253
|
+
if (!validation.valid || !validation.userId) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
254
|
+
|
|
255
|
+
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 }>()
|
|
256
|
+
if (!user) return context.json({ error: 'User not found' }, 401)
|
|
257
|
+
|
|
258
|
+
const revoked = await revokeRefreshToken(DB, refreshToken)
|
|
259
|
+
if (!revoked) return context.json({ error: 'Invalid refresh token' }, 401)
|
|
260
|
+
|
|
261
|
+
const newAccessToken = await generateAccessToken(user.id, user.email, JWT_SECRET, {
|
|
262
|
+
issuer: context.env.JWT_ISSUER,
|
|
263
|
+
audience: context.env.JWT_AUDIENCE,
|
|
264
|
+
}, user.name ?? undefined)
|
|
265
|
+
const newRefreshToken = generateRefreshToken()
|
|
266
|
+
|
|
267
|
+
await saveRefreshToken(DB, user.id, newRefreshToken, REFRESH_TOKEN_EXPIRY_DAYS)
|
|
268
|
+
setCookie(context, 'refresh_token', newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)))
|
|
269
|
+
return context.json({ token: newAccessToken, expiresIn: '15m' }, 200)
|
|
270
|
+
} catch (error) {
|
|
271
|
+
return handleAuthError(context, error, 'Refresh')
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
app.post('/auth/logout', async (context) => {
|
|
276
|
+
try {
|
|
277
|
+
const refreshToken = getCookie(context, 'refresh_token')
|
|
278
|
+
if (refreshToken) await revokeRefreshToken(context.env.DB, refreshToken)
|
|
279
|
+
deleteCookie(context, 'refresh_token', getRefreshTokenDeleteCookieOptions(isRequestSecure(context.req.url)))
|
|
280
|
+
return context.json({ message: 'Logged out' }, 200)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
return handleAuthError(context, error, 'Logout')
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
// 4. Setup & Password Reset
|
|
287
|
+
app.route('/', setupApp)
|
|
288
|
+
app.route('/', passwordResetApp)
|
|
289
|
+
|
|
290
|
+
// 5. Protected CMS API
|
|
291
|
+
const apiProtected = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
292
|
+
apiProtected.use('*', async (context, next) => {
|
|
293
|
+
await authMiddleware(context.env.JWT_SECRET, {
|
|
294
|
+
issuer: context.env.JWT_ISSUER,
|
|
295
|
+
audience: context.env.JWT_AUDIENCE,
|
|
296
|
+
})(context, next)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
apiProtected.route('/settings', settingsApp)
|
|
300
|
+
apiProtected.route('/schema', schemaApp)
|
|
301
|
+
apiProtected.route('/content', notificationsApp)
|
|
302
|
+
apiProtected.route('/content', statsApp)
|
|
303
|
+
apiProtected.route('/content', rotateFieldApp)
|
|
304
|
+
apiProtected.route('/content', draftApp)
|
|
305
|
+
apiProtected.route('/content', contentFeature)
|
|
306
|
+
apiProtected.route('/widget', widgetApp)
|
|
307
|
+
apiProtected.route('/', uploadRoutes)
|
|
308
|
+
|
|
309
|
+
// 6. Public API (must be registered before apiProtected to avoid auth middleware interception)
|
|
310
|
+
const apiPublic = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
311
|
+
apiPublic.use('*', publicRateLimitMiddleware())
|
|
312
|
+
apiPublic.use('*', apiKeyMiddleware())
|
|
313
|
+
apiPublic.route('/', publicRoutes)
|
|
314
|
+
app.route('/api/v1/public', apiPublic)
|
|
315
|
+
|
|
316
|
+
app.get('/api/media/:key', (context) => serveMediaHandler(context))
|
|
317
|
+
app.route('/api', apiProtected)
|
|
318
|
+
app.route('/api/search', searchRouter)
|
|
319
|
+
|
|
320
|
+
// 7. Dashboard SPA — serve static assets from Workers Assets binding
|
|
321
|
+
app.get('/admin', (context) => context.redirect('/admin/', 301))
|
|
322
|
+
app.get('/admin/*', async (context) => {
|
|
323
|
+
if (!context.env.ASSETS) {
|
|
324
|
+
return context.text('Dashboard not configured. Set up the ASSETS binding in wrangler.toml pointing to node_modules/@beechcms/api/assets/dashboard', 503)
|
|
325
|
+
}
|
|
326
|
+
const url = new URL(context.req.url)
|
|
327
|
+
const originalPath = url.pathname
|
|
328
|
+
url.pathname = originalPath.replace(/^\/admin/, '') || '/'
|
|
329
|
+
|
|
330
|
+
let assetResponse = await context.env.ASSETS.fetch(new Request(url.toString(), context.req.raw))
|
|
331
|
+
if (assetResponse.status === 404) {
|
|
332
|
+
// SPA fallback: any unmatched /admin/* route serves index.html
|
|
333
|
+
assetResponse = await context.env.ASSETS.fetch(new Request(new URL('/index.html', context.req.url)))
|
|
334
|
+
}
|
|
335
|
+
// ASSETS returns an immutable Response — wrap it to inject security headers
|
|
336
|
+
const headers = new Headers(assetResponse.headers)
|
|
337
|
+
headers.set('Content-Security-Policy',
|
|
338
|
+
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; connect-src 'self'; frame-ancestors 'none'"
|
|
339
|
+
)
|
|
340
|
+
headers.set('X-Frame-Options', 'DENY')
|
|
341
|
+
headers.set('X-Content-Type-Options', 'nosniff')
|
|
342
|
+
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
343
|
+
return new Response(assetResponse.body, { status: assetResponse.status, headers })
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
return app
|
|
347
|
+
}
|