@beechcms/api 0.4.0-preview.9 → 0.4.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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/assets/index-CewtCjom.css +1 -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/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +10 -10
- package/src/auth/generate-refresh-token.test.ts +19 -0
- package/src/auth/hash-provider.test.ts +46 -0
- package/src/auth/in-memory-hash-provider.ts +13 -0
- package/src/auth/jose-token-service.ts +55 -0
- package/src/auth/login.test.ts +92 -0
- package/src/auth/login.ts +74 -91
- package/src/auth/refresh.ts +5 -127
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +339 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +157 -0
- package/src/features/content/handlers/delete.ts +80 -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 +211 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +283 -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 +101 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +82 -88
- package/src/features/password-reset/reset.ts +92 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +128 -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 +301 -249
- package/src/features/setup/index.ts +91 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +430 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/auth-providers.middleware.ts +32 -0
- package/src/middleware/observability.middleware.ts +52 -0
- package/src/middleware/rate-limit.middleware.ts +41 -0
- package/src/middleware/repository.middleware.ts +60 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +47 -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 +156 -183
- package/src/public/public-edit.ts +159 -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.test.ts +220 -0
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +30 -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/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
- package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
- package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
- package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
- package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
- package/src/rate-limit/no-op-rate-limiter.ts +7 -0
- package/src/search-utils.test.ts +207 -0
- package/src/search-utils.ts +209 -192
- package/src/search.ts +61 -72
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.test.ts +312 -0
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/d1-activity-log.repository.test.ts +136 -0
- package/src/shared/d1-activity-log.repository.ts +101 -0
- package/src/shared/d1-activity-logger.test.ts +82 -0
- package/src/shared/d1-activity-logger.ts +63 -0
- package/src/shared/d1-analytics.repository.test.ts +74 -0
- package/src/shared/d1-analytics.repository.ts +81 -0
- package/src/shared/d1-content-scan.repository.ts +29 -0
- package/src/shared/d1-notification.repository.test.ts +124 -0
- package/src/shared/d1-notification.repository.ts +114 -0
- package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
- package/src/shared/d1-password-reset-token.repository.ts +52 -0
- package/src/shared/d1-search.repository.test.ts +83 -0
- package/src/shared/d1-search.repository.ts +84 -0
- package/src/shared/d1-session.repository.test.ts +121 -0
- package/src/shared/d1-session.repository.ts +98 -0
- package/src/shared/d1-user.repository.test.ts +147 -0
- package/src/shared/d1-user.repository.ts +109 -0
- package/src/shared/d1-widget.repository.test.ts +217 -0
- package/src/shared/d1-widget.repository.ts +337 -0
- package/src/shared/fixed-clock.ts +21 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.test.ts +79 -0
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/in-memory-activity-logger.ts +15 -0
- package/src/shared/in-memory-notification-service.ts +15 -0
- package/src/shared/media.repository.d1.test.ts +103 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- 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.test.ts +54 -0
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +63 -36
- package/src/upload.ts +186 -335
- package/src/widget.ts +208 -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/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
package/src/index.ts
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
1
|
-
import { createBeechApp } from './factory'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Entry point per lo sviluppo locale del monorepo.
|
|
5
|
-
*
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
1
|
+
import { createBeechApp } from './factory'
|
|
2
|
+
|
|
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
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const app = createBeechApp({ seeds })
|
|
21
|
+
|
|
22
|
+
app.get('/', (c) => c.text('Beech API is running (Local Dev Mode)'))
|
|
23
|
+
|
|
24
|
+
export default app
|
package/src/media-utils.ts
CHANGED
|
@@ -1,78 +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
|
-
}
|
|
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,32 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import type { IHashProvider, ITokenService, IClock } from '@beechcms/core'
|
|
3
|
+
import { SystemClock } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv } from '../types'
|
|
5
|
+
import { BcryptHashProvider } from '../auth/bcrypt-hash-provider'
|
|
6
|
+
import { JoseTokenService } from '../auth/jose-token-service'
|
|
7
|
+
|
|
8
|
+
export interface AuthProviderOverrides {
|
|
9
|
+
hashProvider?: IHashProvider
|
|
10
|
+
tokenService?: ITokenService
|
|
11
|
+
clock?: IClock
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const authProvidersMiddleware = (overrides?: AuthProviderOverrides) => {
|
|
15
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
16
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
17
|
+
const hashProvider = overrides?.hashProvider ?? new BcryptHashProvider()
|
|
18
|
+
const tokenService = overrides?.tokenService ?? new JoseTokenService(
|
|
19
|
+
context.env.JWT_SECRET,
|
|
20
|
+
{
|
|
21
|
+
issuer: context.env.JWT_ISSUER,
|
|
22
|
+
audience: context.env.JWT_AUDIENCE,
|
|
23
|
+
},
|
|
24
|
+
resolvedClock,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
context.set('hashProvider', hashProvider)
|
|
28
|
+
context.set('tokenService', tokenService)
|
|
29
|
+
|
|
30
|
+
await next()
|
|
31
|
+
})
|
|
32
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import type { IActivityLogger, INotificationService, IClock, IIdGenerator } from '@beechcms/core'
|
|
3
|
+
import { SystemClock, SystemIdGenerator } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv } from '../types'
|
|
5
|
+
import { D1ActivityLogger } from '../shared/d1-activity-logger'
|
|
6
|
+
import { BackgroundNotificationService } from '../shared/background-notification-service'
|
|
7
|
+
|
|
8
|
+
export interface ObservabilityOverrides {
|
|
9
|
+
activityLogger?: IActivityLogger
|
|
10
|
+
notificationService?: INotificationService
|
|
11
|
+
clock?: IClock
|
|
12
|
+
idGenerator?: IIdGenerator
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Observability middleware.
|
|
17
|
+
*
|
|
18
|
+
* Injects the activity logger and the notification service into the Hono
|
|
19
|
+
* context. Both depend on `executionCtx.waitUntil` to fire-and-forget their
|
|
20
|
+
* persistence work in production. The notification service additionally
|
|
21
|
+
* depends on the notification repository — `repositoryMiddleware` MUST run
|
|
22
|
+
* before this middleware in the factory pipeline.
|
|
23
|
+
*
|
|
24
|
+
* Tests can pass their own implementations via `overrides` to bypass D1.
|
|
25
|
+
*/
|
|
26
|
+
export const observabilityMiddleware = (overrides?: ObservabilityOverrides) => {
|
|
27
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
28
|
+
let scheduleBackgroundTask: ((task: Promise<unknown>) => void) | undefined
|
|
29
|
+
try {
|
|
30
|
+
const executionContext = context.executionCtx
|
|
31
|
+
scheduleBackgroundTask = executionContext.waitUntil.bind(executionContext)
|
|
32
|
+
} catch {
|
|
33
|
+
scheduleBackgroundTask = undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
37
|
+
const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
|
|
38
|
+
|
|
39
|
+
const activityLogger =
|
|
40
|
+
overrides?.activityLogger ??
|
|
41
|
+
new D1ActivityLogger(context.env.DB, resolvedClock, resolvedIdGenerator, scheduleBackgroundTask)
|
|
42
|
+
|
|
43
|
+
const notificationService =
|
|
44
|
+
overrides?.notificationService ??
|
|
45
|
+
new BackgroundNotificationService(context.get('notificationRepository'), scheduleBackgroundTask)
|
|
46
|
+
|
|
47
|
+
context.set('activityLogger', activityLogger)
|
|
48
|
+
context.set('notificationService', notificationService)
|
|
49
|
+
|
|
50
|
+
await next()
|
|
51
|
+
})
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { createMiddleware } from 'hono/factory'
|
|
3
|
+
import type { IRateLimiter } from '@beechcms/core'
|
|
4
|
+
import type { AppEnv, Env } from '../types'
|
|
5
|
+
import { CloudflareRateLimiter } from '../rate-limit/cloudflare-rate-limiter'
|
|
6
|
+
import { NoOpRateLimiter } from '../rate-limit/no-op-rate-limiter'
|
|
7
|
+
|
|
8
|
+
export type RateLimiterName =
|
|
9
|
+
| 'login'
|
|
10
|
+
| 'tokenRefresh'
|
|
11
|
+
| 'forgotPassword'
|
|
12
|
+
| 'resetPassword'
|
|
13
|
+
| 'publicApiRead'
|
|
14
|
+
| 'publicApiWrite'
|
|
15
|
+
|
|
16
|
+
export interface IRateLimiterRegistry {
|
|
17
|
+
getLimiter(name: RateLimiterName): IRateLimiter
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const NO_OP = new NoOpRateLimiter()
|
|
21
|
+
|
|
22
|
+
function buildDefaultRegistry(env: Env): IRateLimiterRegistry {
|
|
23
|
+
const limiters: Record<RateLimiterName, IRateLimiter> = {
|
|
24
|
+
login: env.LOGIN_RATE_LIMITER ? new CloudflareRateLimiter(env.LOGIN_RATE_LIMITER) : NO_OP,
|
|
25
|
+
tokenRefresh: env.REFRESH_RATE_LIMITER ? new CloudflareRateLimiter(env.REFRESH_RATE_LIMITER) : NO_OP,
|
|
26
|
+
forgotPassword: env.FORGOT_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.FORGOT_PASSWORD_RATE_LIMITER) : NO_OP,
|
|
27
|
+
resetPassword: env.RESET_PASSWORD_RATE_LIMITER ? new CloudflareRateLimiter(env.RESET_PASSWORD_RATE_LIMITER) : NO_OP,
|
|
28
|
+
publicApiRead: env.PUBLIC_READ_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_READ_RATE_LIMITER) : NO_OP,
|
|
29
|
+
publicApiWrite: env.PUBLIC_WRITE_RATE_LIMITER ? new CloudflareRateLimiter(env.PUBLIC_WRITE_RATE_LIMITER) : NO_OP,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return { getLimiter: (name) => limiters[name] }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const rateLimiterMiddleware = (overrides?: { registry?: IRateLimiterRegistry }) => {
|
|
36
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
37
|
+
const registry = overrides?.registry ?? buildDefaultRegistry(context.env)
|
|
38
|
+
context.set('rateLimiters', registry)
|
|
39
|
+
await next()
|
|
40
|
+
})
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import { D1ContentRepository } from '../shared/content.repository.d1'
|
|
3
|
+
import { D1IdempotencyRepository } from '../shared/idempotency.repository.d1'
|
|
4
|
+
import { D1MediaRepository } from '../shared/media.repository.d1'
|
|
5
|
+
import { D1SystemStatsRepository } from '../shared/system-stats.repository.d1'
|
|
6
|
+
import { D1UserRepository } from '../shared/d1-user.repository'
|
|
7
|
+
import { D1SessionRepository } from '../shared/d1-session.repository'
|
|
8
|
+
import { D1PasswordResetTokenRepository } from '../shared/d1-password-reset-token.repository'
|
|
9
|
+
import { D1ActivityLogRepository } from '../shared/d1-activity-log.repository'
|
|
10
|
+
import { D1NotificationRepository } from '../shared/d1-notification.repository'
|
|
11
|
+
import { D1WidgetRepository } from '../shared/d1-widget.repository'
|
|
12
|
+
import { D1SearchRepository } from '../shared/d1-search.repository'
|
|
13
|
+
import { D1AnalyticsRepository } from '../shared/d1-analytics.repository'
|
|
14
|
+
import { D1ContentScanRepository } from '../shared/d1-content-scan.repository'
|
|
15
|
+
import { SystemClock, SystemIdGenerator } from '@beechcms/core'
|
|
16
|
+
import type { ContentRepository, IdempotencyRepository, MediaRepository, SystemStatsRepository, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogRepository, INotificationRepository, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, IClock, IIdGenerator } from '@beechcms/core'
|
|
17
|
+
import type { Env, Variables } from '../types'
|
|
18
|
+
|
|
19
|
+
interface RepositoryOverrides {
|
|
20
|
+
repository?: ContentRepository
|
|
21
|
+
idempotencyRepository?: IdempotencyRepository
|
|
22
|
+
mediaRepository?: MediaRepository
|
|
23
|
+
systemStatsRepository?: SystemStatsRepository
|
|
24
|
+
userRepository?: IUserRepository
|
|
25
|
+
sessionRepository?: ISessionRepository
|
|
26
|
+
passwordResetTokenRepository?: IPasswordResetTokenRepository
|
|
27
|
+
activityLogRepository?: IActivityLogRepository
|
|
28
|
+
notificationRepository?: INotificationRepository
|
|
29
|
+
widgetRepository?: IWidgetRepository
|
|
30
|
+
searchRepository?: ISearchRepository
|
|
31
|
+
analyticsRepository?: IAnalyticsRepository
|
|
32
|
+
contentScanRepository?: IContentScanRepository
|
|
33
|
+
clock?: IClock
|
|
34
|
+
idGenerator?: IIdGenerator
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const repositoryMiddleware = (overrides?: RepositoryOverrides) => {
|
|
38
|
+
return createMiddleware<{ Bindings: Env; Variables: Variables }>(async (context, next) => {
|
|
39
|
+
const resolvedClock = overrides?.clock ?? SystemClock
|
|
40
|
+
const resolvedIdGenerator = overrides?.idGenerator ?? SystemIdGenerator
|
|
41
|
+
const database = context.env.DB
|
|
42
|
+
|
|
43
|
+
context.set('repository', overrides?.repository ?? new D1ContentRepository(database))
|
|
44
|
+
context.set('idempotencyRepository', overrides?.idempotencyRepository ?? new D1IdempotencyRepository(database))
|
|
45
|
+
context.set('mediaRepository', overrides?.mediaRepository ?? new D1MediaRepository(database))
|
|
46
|
+
context.set('systemStatsRepository', overrides?.systemStatsRepository ?? new D1SystemStatsRepository(database))
|
|
47
|
+
context.set('userRepository', overrides?.userRepository ?? new D1UserRepository(database))
|
|
48
|
+
context.set('sessionRepository', overrides?.sessionRepository ?? new D1SessionRepository(database, resolvedClock))
|
|
49
|
+
context.set('passwordResetTokenRepository', overrides?.passwordResetTokenRepository ?? new D1PasswordResetTokenRepository(database, resolvedIdGenerator))
|
|
50
|
+
context.set('activityLogRepository', overrides?.activityLogRepository ?? new D1ActivityLogRepository(database))
|
|
51
|
+
context.set('notificationRepository', overrides?.notificationRepository ?? new D1NotificationRepository(database, resolvedClock, resolvedIdGenerator))
|
|
52
|
+
context.set('widgetRepository', overrides?.widgetRepository ?? new D1WidgetRepository(database))
|
|
53
|
+
context.set('searchRepository', overrides?.searchRepository ?? new D1SearchRepository(database))
|
|
54
|
+
context.set('analyticsRepository', overrides?.analyticsRepository ?? new D1AnalyticsRepository(database, resolvedClock))
|
|
55
|
+
context.set('contentScanRepository', overrides?.contentScanRepository ?? new D1ContentScanRepository(database))
|
|
56
|
+
context.set('clock', resolvedClock)
|
|
57
|
+
context.set('idGenerator', resolvedIdGenerator)
|
|
58
|
+
await next()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createMiddleware } from 'hono/factory'
|
|
2
|
+
import { BeechBucket } from '@beechcms/core'
|
|
3
|
+
import { AppEnv } from '../types'
|
|
4
|
+
import { createBucketProvider } from '../shared/storage/factory'
|
|
5
|
+
|
|
6
|
+
interface StorageOverrides {
|
|
7
|
+
bucket?: BeechBucket
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const storageMiddleware = (overrides?: StorageOverrides) => {
|
|
11
|
+
return createMiddleware<AppEnv>(async (context, next) => {
|
|
12
|
+
if (overrides?.bucket) {
|
|
13
|
+
context.set('bucket', overrides.bucket)
|
|
14
|
+
} else {
|
|
15
|
+
// Determine base URL for getUrl() only if we need to create the provider
|
|
16
|
+
const baseUrl = context.env.MEDIA_BASE_URL?.trim().replace(/\/$/, '') || new URL(context.req.url).origin
|
|
17
|
+
context.set('bucket', createBucketProvider(context.env, baseUrl))
|
|
18
|
+
}
|
|
19
|
+
await next()
|
|
20
|
+
})
|
|
21
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,67 +1,47 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import type { Context, Next } from 'hono'
|
|
3
|
-
import { HTTPException } from 'hono/http-exception'
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import type { Context, Next } from 'hono'
|
|
3
|
+
import { HTTPException } from 'hono/http-exception'
|
|
4
|
+
import type { Env, Variables } from './types'
|
|
5
|
+
|
|
6
|
+
export type JwtPayload = {
|
|
7
|
+
sub: string
|
|
8
|
+
email?: string
|
|
9
|
+
name?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const UNAUTHORIZED_JSON = JSON.stringify({ error: 'Unauthorized' })
|
|
13
|
+
|
|
14
|
+
function unauthorizedResponse() {
|
|
15
|
+
return new Response(UNAUTHORIZED_JSON, {
|
|
16
|
+
status: 401,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* JWT authentication middleware. Reads the Bearer token from the Authorization
|
|
23
|
+
* header and delegates verification to the ITokenService injected in context.
|
|
24
|
+
* Returns 401 on any missing or invalid token; never exposes failure details.
|
|
25
|
+
*/
|
|
26
|
+
export function authMiddleware() {
|
|
27
|
+
return async (c: Context<{ Bindings: Env; Variables: Variables }>, next: Next): Promise<Response | void> => {
|
|
28
|
+
const authHeader = c.req.header('Authorization')
|
|
29
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
30
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const token = authHeader.slice(7)
|
|
34
|
+
if (!token) {
|
|
35
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const claims = await c.get('tokenService').verify(token)
|
|
39
|
+
|
|
40
|
+
if (!claims) {
|
|
41
|
+
throw new HTTPException(401, { res: unauthorizedResponse() })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
c.set('jwtPayload', claims as JwtPayload)
|
|
45
|
+
await next()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -1,23 +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
|
-
}
|
|
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
|
+
}
|