@beechcms/api 0.4.0-preview.11 → 0.4.0-preview.13

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.
Files changed (86) hide show
  1. package/assets/dashboard/BeechLogo.svg +18 -18
  2. package/assets/dashboard/BeechLogoLIght.svg +48 -48
  3. package/assets/dashboard/assets/index-CewtCjom.css +1 -0
  4. package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
  5. package/assets/dashboard/beechLogoDark.svg +48 -48
  6. package/assets/dashboard/index.html +18 -18
  7. package/assets/dashboard/sol.svg +3 -3
  8. package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
  9. package/migrations/0000_v040_base.sql +213 -213
  10. package/package.json +2 -2
  11. package/src/auth/constants.ts +10 -10
  12. package/src/auth/login.ts +91 -91
  13. package/src/auth/refresh.ts +127 -127
  14. package/src/factory.ts +347 -325
  15. package/src/features/content/constants.ts +10 -10
  16. package/src/features/content/handlers/create.ts +153 -163
  17. package/src/features/content/handlers/delete.ts +76 -85
  18. package/src/features/content/handlers/facets.ts +45 -45
  19. package/src/features/content/handlers/get.ts +116 -116
  20. package/src/features/content/handlers/list.ts +88 -88
  21. package/src/features/content/handlers/update.ts +207 -216
  22. package/src/features/content/index.ts +20 -20
  23. package/src/features/draft/draft.handler.ts +272 -272
  24. package/src/features/draft/index.ts +1 -1
  25. package/src/features/email/email.provider.ts +38 -38
  26. package/src/features/email/email.service.ts +80 -80
  27. package/src/features/email/email.types.ts +98 -98
  28. package/src/features/email/index.ts +28 -28
  29. package/src/features/email/providers/resend.ts +63 -63
  30. package/src/features/email/templates/password-changed.ts +59 -59
  31. package/src/features/email/templates/password-reset.ts +64 -64
  32. package/src/features/email/templates/shell.ts +92 -93
  33. package/src/features/notifications/index.ts +1 -1
  34. package/src/features/notifications/notifications.handler.ts +130 -88
  35. package/src/features/password-reset/index.ts +15 -15
  36. package/src/features/password-reset/request.ts +106 -88
  37. package/src/features/password-reset/reset.ts +128 -110
  38. package/src/features/rotate-field/index.ts +1 -1
  39. package/src/features/rotate-field/rotate-field.handler.ts +132 -82
  40. package/src/features/rotate-field/rotate-field.schema.ts +13 -9
  41. package/src/features/schema/schema.handler.ts +16 -16
  42. package/src/features/settings/settings.handler.ts +414 -267
  43. package/src/features/setup/index.ts +96 -59
  44. package/src/features/stats/index.ts +1 -1
  45. package/src/features/stats/stats.handler.ts +458 -395
  46. package/src/index.ts +24 -24
  47. package/src/media-utils.ts +78 -78
  48. package/src/middleware/repository.middleware.ts +24 -18
  49. package/src/middleware/storage.middleware.ts +21 -0
  50. package/src/middleware.ts +67 -67
  51. package/src/public/access-policy.ts +23 -23
  52. package/src/public/api-key-middleware.ts +53 -53
  53. package/src/public/index.ts +12 -12
  54. package/src/public/problem-details.ts +48 -42
  55. package/src/public/public-add.ts +166 -166
  56. package/src/public/public-edit.ts +158 -158
  57. package/src/public/public-errors.ts +15 -15
  58. package/src/public/public-read.ts +216 -216
  59. package/src/public/public-routes.ts +84 -31
  60. package/src/public/query-builder.ts +152 -152
  61. package/src/public/rate-limit-middleware.ts +42 -42
  62. package/src/public/response-builder.ts +26 -26
  63. package/src/public/sanitize.ts +65 -65
  64. package/src/public/slug-utils.ts +14 -14
  65. package/src/search-utils.ts +192 -192
  66. package/src/search.ts +72 -72
  67. package/src/shared/activity-logger.ts +79 -79
  68. package/src/shared/apply-policies.ts +63 -63
  69. package/src/shared/base.repository.d1.ts +28 -28
  70. package/src/shared/content-utils.ts +82 -108
  71. package/src/shared/content.repository.d1.ts +382 -382
  72. package/src/shared/fts-sync.ts +4 -4
  73. package/src/shared/idempotency.repository.d1.ts +56 -45
  74. package/src/shared/media.repository.d1.ts +64 -0
  75. package/src/shared/notification-service.ts +56 -56
  76. package/src/shared/query-utils.ts +137 -137
  77. package/src/shared/storage/factory.ts +40 -0
  78. package/src/shared/storage/r2-binding-bucket.ts +81 -0
  79. package/src/shared/storage/s3-bucket.ts +163 -0
  80. package/src/shared/storage-utils.ts +36 -36
  81. package/src/shared/system-stats.repository.d1.ts +44 -0
  82. package/src/types.ts +46 -41
  83. package/src/upload.ts +179 -331
  84. package/src/widget.ts +349 -349
  85. package/assets/dashboard/assets/index-9Ch2xWJr.js +0 -554
  86. package/assets/dashboard/assets/index-ye3325L9.css +0 -1
@@ -0,0 +1,81 @@
1
+ import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
2
+
3
+ /**
4
+ * Implementation of BeechBucket using Cloudflare R2 native binding.
5
+ * Used for local development (Miniflare) or native Cloudflare Workers deployment.
6
+ */
7
+ export class R2BindingBucket implements BeechBucket {
8
+ constructor(
9
+ private bucket: R2Bucket,
10
+ private baseUrl: string
11
+ ) {}
12
+
13
+ async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
14
+ await this.bucket.put(key, body, {
15
+ httpMetadata: { contentType: options?.contentType },
16
+ customMetadata: options?.metadata
17
+ })
18
+ }
19
+
20
+ async get(key: string): Promise<GetBucketResult | null> {
21
+ const object = await this.bucket.get(key)
22
+ if (!object) return null
23
+
24
+ return {
25
+ body: object.body,
26
+ contentType: object.httpMetadata?.contentType,
27
+ size: object.size,
28
+ metadata: object.customMetadata
29
+ }
30
+ }
31
+
32
+ async delete(key: string): Promise<void> {
33
+ await this.bucket.delete(key)
34
+ }
35
+
36
+ async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
37
+ const object = await this.bucket.head(key)
38
+ if (!object) return null
39
+
40
+ return {
41
+ size: object.size,
42
+ contentType: object.httpMetadata?.contentType,
43
+ metadata: object.customMetadata
44
+ }
45
+ }
46
+
47
+ getUrl(key: string): string {
48
+ // If baseUrl is just the origin, we use the proxied route.
49
+ // If it's a custom CDN/R2 domain, it might not need /api/media/
50
+ // but for now, we maintain the contract: the provider decides the full URL.
51
+ if (this.baseUrl.includes('/api/media')) {
52
+ return `${this.baseUrl}/${encodeURIComponent(key)}`
53
+ }
54
+ return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
55
+ }
56
+
57
+ async getTotalSize(): Promise<number> {
58
+ let total = 0
59
+ let cursor: string | undefined
60
+ do {
61
+ const result: R2Objects = await this.bucket.list({ cursor })
62
+ for (const obj of result.objects) {
63
+ total += obj.size
64
+ }
65
+ cursor = result.truncated ? result.cursor : undefined
66
+ } while (cursor)
67
+ return total
68
+ }
69
+
70
+ async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
71
+ const result = await this.bucket.list({
72
+ prefix: options?.prefix,
73
+ limit: options?.limit,
74
+ cursor: options?.cursor
75
+ })
76
+ return {
77
+ objects: result.objects.map(obj => ({ key: obj.key, size: obj.size })),
78
+ cursor: result.truncated ? result.cursor : undefined
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,163 @@
1
+ import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'
2
+ import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
3
+
4
+ /**
5
+ * Implementation of BeechBucket using S3-compatible API (AWS SDK).
6
+ * Used for production when connecting to R2 or any other S3 provider via HTTP.
7
+ */
8
+ export class S3Bucket implements BeechBucket {
9
+ private client: S3Client
10
+ private bucketName: string
11
+ private baseUrl: string
12
+
13
+ private cdnUrl: string | null
14
+
15
+ constructor(config: {
16
+ endpoint: string,
17
+ accessKeyId: string,
18
+ secretAccessKey: string,
19
+ bucketName: string,
20
+ baseUrl: string,
21
+ cdnUrl?: string,
22
+ }) {
23
+ this.client = new S3Client({
24
+ region: 'auto',
25
+ endpoint: config.endpoint,
26
+ credentials: {
27
+ accessKeyId: config.accessKeyId,
28
+ secretAccessKey: config.secretAccessKey,
29
+ },
30
+ forcePathStyle: true,
31
+ })
32
+ this.bucketName = config.bucketName
33
+ this.baseUrl = config.baseUrl
34
+ this.cdnUrl = config.cdnUrl ?? null
35
+ }
36
+
37
+ async put(key: string, body: ArrayBuffer | Uint8Array | ReadableStream, options?: PutBucketOptions): Promise<void> {
38
+ const command = new PutObjectCommand({
39
+ Bucket: this.bucketName,
40
+ Key: key,
41
+ Body: body instanceof ReadableStream ? await this.streamToUint8Array(body) : new Uint8Array(body as ArrayBuffer),
42
+ ContentType: options?.contentType,
43
+ Metadata: options?.metadata
44
+ })
45
+ await this.client.send(command)
46
+ }
47
+
48
+ private async streamToUint8Array(stream: ReadableStream): Promise<Uint8Array> {
49
+ const reader = stream.getReader()
50
+ const chunks: Uint8Array[] = []
51
+ let totalLength = 0
52
+ while (true) {
53
+ const { done, value } = await reader.read()
54
+ if (done) break
55
+ chunks.push(value)
56
+ totalLength += value.length
57
+ }
58
+ const result = new Uint8Array(totalLength)
59
+ let offset = 0
60
+ for (const chunk of chunks) {
61
+ result.set(chunk, offset)
62
+ offset += chunk.length
63
+ }
64
+ return result
65
+ }
66
+
67
+ async get(key: string): Promise<GetBucketResult | null> {
68
+ try {
69
+ const command = new GetObjectCommand({
70
+ Bucket: this.bucketName,
71
+ Key: key
72
+ })
73
+ const response = await this.client.send(command)
74
+
75
+ if (!response.Body) return null
76
+
77
+ const rawBody = response.Body as any
78
+ const body: ReadableStream = typeof rawBody.transformToWebStream === 'function'
79
+ ? rawBody.transformToWebStream()
80
+ : rawBody as ReadableStream
81
+
82
+ return {
83
+ body,
84
+ contentType: response.ContentType,
85
+ size: response.ContentLength ?? 0,
86
+ metadata: response.Metadata
87
+ }
88
+ } catch (err: any) {
89
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound' || err.message === 'NoSuchKey') {
90
+ return null
91
+ }
92
+ // Rethrow other errors (AccessDenied, Timeout, etc.)
93
+ throw err
94
+ }
95
+ }
96
+
97
+ async delete(key: string): Promise<void> {
98
+ const command = new DeleteObjectCommand({
99
+ Bucket: this.bucketName,
100
+ Key: key
101
+ })
102
+ await this.client.send(command)
103
+ }
104
+
105
+ async head(key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> {
106
+ try {
107
+ const command = new HeadObjectCommand({
108
+ Bucket: this.bucketName,
109
+ Key: key
110
+ })
111
+ const response = await this.client.send(command)
112
+ return {
113
+ size: response.ContentLength ?? 0,
114
+ contentType: response.ContentType,
115
+ metadata: response.Metadata
116
+ }
117
+ } catch (err: any) {
118
+ if (err.name === 'NoSuchKey' || err.name === 'NotFound') return null
119
+ throw err
120
+ }
121
+ }
122
+
123
+ getUrl(key: string): string {
124
+ if (this.cdnUrl) {
125
+ return `${this.cdnUrl}/${encodeURIComponent(key)}`
126
+ }
127
+ return `${this.baseUrl}/api/media/${encodeURIComponent(key)}`
128
+ }
129
+
130
+ async getTotalSize(): Promise<number> {
131
+ let total = 0
132
+ let token: string | undefined
133
+ do {
134
+ const command = new ListObjectsV2Command({
135
+ Bucket: this.bucketName,
136
+ ContinuationToken: token
137
+ })
138
+ const response = await this.client.send(command)
139
+ for (const obj of response.Contents ?? []) {
140
+ total += obj.Size ?? 0
141
+ }
142
+ token = response.NextContinuationToken
143
+ } while (token)
144
+ return total
145
+ }
146
+
147
+ async list(options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> {
148
+ const command = new ListObjectsV2Command({
149
+ Bucket: this.bucketName,
150
+ Prefix: options?.prefix,
151
+ MaxKeys: options?.limit,
152
+ ContinuationToken: options?.cursor
153
+ })
154
+ const response = await this.client.send(command)
155
+ return {
156
+ objects: (response.Contents ?? []).map(obj => ({
157
+ key: obj.Key ?? '',
158
+ size: obj.Size ?? 0
159
+ })),
160
+ cursor: response.NextContinuationToken
161
+ }
162
+ }
163
+ }
@@ -1,36 +1,36 @@
1
- import { S3Client, ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
2
-
3
- /**
4
- * Calcola la dimensione totale occupata in un bucket R2 (in byte).
5
- * Nota: esegue una scansione completa del bucket. Per bucket molto grandi
6
- * andrebbe implementata una cache o usato Cloudflare Logpush/Analytics.
7
- */
8
- export async function getBucketSize(client: S3Client, bucketName: string): Promise<number> {
9
- let totalSize = 0
10
- let isTruncatedFlag = true
11
- let continuationToken: string | undefined = undefined
12
-
13
- try {
14
- while (isTruncatedFlag) {
15
- const command = new ListObjectsV2Command({
16
- Bucket: bucketName,
17
- ContinuationToken: continuationToken,
18
- })
19
-
20
- const response = (await client.send(command)) as ListObjectsV2CommandOutput
21
-
22
- if (response.Contents) {
23
- for (const obj of response.Contents) {
24
- totalSize += obj.Size ?? 0
25
- }
26
- }
27
-
28
- isTruncatedFlag = response.IsTruncated ?? false
29
- continuationToken = response.NextContinuationToken
30
- }
31
- return totalSize
32
- } catch (err) {
33
- console.error('Error calculating bucket size:', err)
34
- return 0
35
- }
36
- }
1
+ import { S3Client, ListObjectsV2Command, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
2
+
3
+ /**
4
+ * Calcola la dimensione totale occupata in un bucket R2 (in byte).
5
+ * Nota: esegue una scansione completa del bucket. Per bucket molto grandi
6
+ * andrebbe implementata una cache o usato Cloudflare Logpush/Analytics.
7
+ */
8
+ export async function getBucketSize(client: S3Client, bucketName: string): Promise<number> {
9
+ let totalSize = 0
10
+ let isTruncatedFlag = true
11
+ let continuationToken: string | undefined = undefined
12
+
13
+ try {
14
+ while (isTruncatedFlag) {
15
+ const command = new ListObjectsV2Command({
16
+ Bucket: bucketName,
17
+ ContinuationToken: continuationToken,
18
+ })
19
+
20
+ const response = (await client.send(command)) as ListObjectsV2CommandOutput
21
+
22
+ if (response.Contents) {
23
+ for (const obj of response.Contents) {
24
+ totalSize += obj.Size ?? 0
25
+ }
26
+ }
27
+
28
+ isTruncatedFlag = response.IsTruncated ?? false
29
+ continuationToken = response.NextContinuationToken
30
+ }
31
+ return totalSize
32
+ } catch (err) {
33
+ console.error('Error calculating bucket size:', err)
34
+ throw new Error('Failed to calculate bucket size. Check your R2/S3 credentials and endpoint.')
35
+ }
36
+ }
@@ -0,0 +1,44 @@
1
+ import { SystemStatsRepository } from '@beechcms/core'
2
+
3
+ /**
4
+ * Implementation of SystemStatsRepository using Cloudflare D1.
5
+ * Manages global system statistics such as total storage usage.
6
+ */
7
+ export class D1SystemStatsRepository implements SystemStatsRepository {
8
+ constructor(private database: D1Database) {}
9
+
10
+ /**
11
+ * Increments the total storage usage count.
12
+ */
13
+ async incrementStorage(byteCount: number): Promise<void> {
14
+ await this.database.prepare(
15
+ "UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
16
+ ).bind(byteCount).run()
17
+ }
18
+
19
+ /**
20
+ * Decrements the total storage usage count, ensuring it doesn't fall below zero.
21
+ */
22
+ async decrementStorage(byteCount: number): Promise<void> {
23
+ await this.database.prepare(
24
+ "UPDATE system_stats SET value = MAX(0, CAST(value AS INTEGER) - ?) WHERE id = 'total_storage_bytes'"
25
+ ).bind(byteCount).run()
26
+ }
27
+
28
+ /**
29
+ * Sets the total storage usage count to a specific value.
30
+ */
31
+ async setStorage(byteCount: number): Promise<void> {
32
+ await this.database.prepare(
33
+ "UPDATE system_stats SET value = ? WHERE id = 'total_storage_bytes'"
34
+ ).bind(String(byteCount)).run()
35
+ }
36
+
37
+ /**
38
+ * Retrieves the current total storage usage in bytes.
39
+ */
40
+ async getStorageUsage(): Promise<number> {
41
+ const statsRecordResult = await this.database.prepare("SELECT value FROM system_stats WHERE id = 'total_storage_bytes'").first<{ value: string }>()
42
+ return statsRecordResult ? parseInt(statsRecordResult.value, 10) : 0
43
+ }
44
+ }
package/src/types.ts CHANGED
@@ -1,41 +1,46 @@
1
- /// <reference types="@cloudflare/workers-types" />
2
- import type { Seed, ContentRepository, IdempotencyRepository } from '@beechcms/core'
3
-
4
- export interface Env {
5
- DB: D1Database
6
- JWT_SECRET: string
7
- JWT_ISSUER?: string
8
- JWT_AUDIENCE?: string
9
- R2_ACCESS_KEY_ID?: string
10
- R2_SECRET_ACCESS_KEY?: string
11
- R2_ENDPOINT?: string
12
- R2_BUCKET_NAME?: string
13
- LOGIN_RATE_LIMITER?: RateLimit
14
- REFRESH_RATE_LIMITER?: RateLimit
15
- PUBLIC_READ_RATE_LIMITER?: RateLimit
16
- PUBLIC_WRITE_RATE_LIMITER?: RateLimit
17
- CORS_ORIGINS?: string
18
- PUBLIC_READ_API_KEY?: string
19
- PUBLIC_WRITE_API_KEY?: string
20
- PUBLIC_PUBLISHED_ONLY?: string
21
- PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
22
- MEDIA_BASE_URL?: string
23
- RESEND_API_KEY?: string
24
- APP_URL?: string
25
- EMAIL_FROM?: string
26
- FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
27
- RESET_PASSWORD_RATE_LIMITER?: RateLimit
28
- ENV?: string
29
- DATE_FORMAT?: string
30
- ASSETS?: Fetcher
31
- }
32
-
33
- export interface Variables {
34
- jwtPayload: { sub: string; email?: string }
35
- getSeed: (slug: string) => Seed | null
36
- seedRegistry: Record<string, Seed>
37
- repository: ContentRepository
38
- idempotencyRepository: IdempotencyRepository
39
- }
40
-
41
- export type AppEnv = { Bindings: Env; Variables: Variables }
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
3
+
4
+ export interface Env {
5
+ DB: D1Database
6
+ JWT_SECRET: string
7
+ JWT_ISSUER?: string
8
+ JWT_AUDIENCE?: string
9
+ R2_ACCESS_KEY_ID?: string
10
+ R2_SECRET_ACCESS_KEY?: string
11
+ R2_ENDPOINT?: string
12
+ R2_BUCKET_NAME?: string
13
+ LOGIN_RATE_LIMITER?: RateLimit
14
+ REFRESH_RATE_LIMITER?: RateLimit
15
+ PUBLIC_READ_RATE_LIMITER?: RateLimit
16
+ PUBLIC_WRITE_RATE_LIMITER?: RateLimit
17
+ CORS_ORIGINS?: string
18
+ PUBLIC_READ_API_KEY?: string
19
+ PUBLIC_WRITE_API_KEY?: string
20
+ PUBLIC_PUBLISHED_ONLY?: string
21
+ PUBLIC_IDEMPOTENCY_TTL_SECONDS?: string
22
+ MEDIA_BASE_URL?: string
23
+ MEDIA_CDN_URL?: string
24
+ RESEND_API_KEY?: string
25
+ APP_URL?: string
26
+ EMAIL_FROM?: string
27
+ FORGOT_PASSWORD_RATE_LIMITER?: RateLimit
28
+ RESET_PASSWORD_RATE_LIMITER?: RateLimit
29
+ ENV?: string
30
+ DATE_FORMAT?: string
31
+ ASSETS?: Fetcher
32
+ MEDIA_BUCKET?: R2Bucket
33
+ }
34
+
35
+ export interface Variables {
36
+ jwtPayload: { sub: string; email?: string }
37
+ getSeed: (slug: string) => Seed | null
38
+ seedRegistry: Record<string, Seed>
39
+ repository: ContentRepository
40
+ idempotencyRepository: IdempotencyRepository
41
+ bucket: BeechBucket
42
+ mediaRepository: MediaRepository
43
+ systemStatsRepository: SystemStatsRepository
44
+ }
45
+
46
+ export type AppEnv = { Bindings: Env; Variables: Variables }