@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/search.ts
CHANGED
|
@@ -1,72 +1,72 @@
|
|
|
1
|
-
// apps/api/src/search.ts
|
|
2
|
-
|
|
3
|
-
import { Hono } from "hono"
|
|
4
|
-
import type { Env, Variables } from "./types"
|
|
5
|
-
import { authMiddleware } from "./middleware"
|
|
6
|
-
import {
|
|
7
|
-
buildFtsQuery,
|
|
8
|
-
encodeCursor,
|
|
9
|
-
mapFtsRow,
|
|
10
|
-
type FtsRow,
|
|
11
|
-
type SearchResponse,
|
|
12
|
-
} from "./search-utils"
|
|
13
|
-
|
|
14
|
-
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
15
|
-
|
|
16
|
-
searchRouter.use("*", async (c, next) => {
|
|
17
|
-
return authMiddleware(c.env.JWT_SECRET, {
|
|
18
|
-
issuer: c.env.JWT_ISSUER,
|
|
19
|
-
audience: c.env.JWT_AUDIENCE,
|
|
20
|
-
})(c, next)
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
24
|
-
searchRouter.get("/", async (c) => {
|
|
25
|
-
const q = c.req.query("q")?.trim() ?? ""
|
|
26
|
-
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
27
|
-
const status = c.req.query("status") ?? null
|
|
28
|
-
const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
|
|
29
|
-
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
30
|
-
const cursor = c.req.query("cursor") ?? null
|
|
31
|
-
|
|
32
|
-
if (q.length < 2) {
|
|
33
|
-
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const seeds = Object.values(c.get('seedRegistry'))
|
|
37
|
-
|
|
38
|
-
let queryParts: ReturnType<typeof buildFtsQuery>
|
|
39
|
-
try {
|
|
40
|
-
queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
|
|
41
|
-
} catch (e) {
|
|
42
|
-
if ((e as Error).message === "EMPTY_QUERY") {
|
|
43
|
-
return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
|
|
44
|
-
}
|
|
45
|
-
throw e
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const { sql, binds, countSql, countBinds } = queryParts
|
|
49
|
-
|
|
50
|
-
const [ftsResult, countResult] = await Promise.all([
|
|
51
|
-
c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
|
|
52
|
-
c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
|
|
53
|
-
])
|
|
54
|
-
|
|
55
|
-
const rows = ftsResult.results ?? []
|
|
56
|
-
const total = countResult?.total ?? 0
|
|
57
|
-
|
|
58
|
-
const hasMore = rows.length > limit
|
|
59
|
-
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
|
60
|
-
|
|
61
|
-
const nextCursor = hasMore
|
|
62
|
-
? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
|
|
63
|
-
: null
|
|
64
|
-
|
|
65
|
-
if (pageRows.length === 0) {
|
|
66
|
-
return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const items = pageRows.map(row => mapFtsRow(row))
|
|
70
|
-
|
|
71
|
-
return c.json({ items, nextCursor, total } satisfies SearchResponse)
|
|
72
|
-
})
|
|
1
|
+
// apps/api/src/search.ts
|
|
2
|
+
|
|
3
|
+
import { Hono } from "hono"
|
|
4
|
+
import type { Env, Variables } from "./types"
|
|
5
|
+
import { authMiddleware } from "./middleware"
|
|
6
|
+
import {
|
|
7
|
+
buildFtsQuery,
|
|
8
|
+
encodeCursor,
|
|
9
|
+
mapFtsRow,
|
|
10
|
+
type FtsRow,
|
|
11
|
+
type SearchResponse,
|
|
12
|
+
} from "./search-utils"
|
|
13
|
+
|
|
14
|
+
export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
15
|
+
|
|
16
|
+
searchRouter.use("*", async (c, next) => {
|
|
17
|
+
return authMiddleware(c.env.JWT_SECRET, {
|
|
18
|
+
issuer: c.env.JWT_ISSUER,
|
|
19
|
+
audience: c.env.JWT_AUDIENCE,
|
|
20
|
+
})(c, next)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
|
|
24
|
+
searchRouter.get("/", async (c) => {
|
|
25
|
+
const q = c.req.query("q")?.trim() ?? ""
|
|
26
|
+
const schemaSlug = c.req.query("schema_slug") ?? null
|
|
27
|
+
const status = c.req.query("status") ?? null
|
|
28
|
+
const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
|
|
29
|
+
const limit = Math.min(Math.max(rawLimit, 1), 50)
|
|
30
|
+
const cursor = c.req.query("cursor") ?? null
|
|
31
|
+
|
|
32
|
+
if (q.length < 2) {
|
|
33
|
+
return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const seeds = Object.values(c.get('seedRegistry'))
|
|
37
|
+
|
|
38
|
+
let queryParts: ReturnType<typeof buildFtsQuery>
|
|
39
|
+
try {
|
|
40
|
+
queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if ((e as Error).message === "EMPTY_QUERY") {
|
|
43
|
+
return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
|
|
44
|
+
}
|
|
45
|
+
throw e
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { sql, binds, countSql, countBinds } = queryParts
|
|
49
|
+
|
|
50
|
+
const [ftsResult, countResult] = await Promise.all([
|
|
51
|
+
c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
|
|
52
|
+
c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const rows = ftsResult.results ?? []
|
|
56
|
+
const total = countResult?.total ?? 0
|
|
57
|
+
|
|
58
|
+
const hasMore = rows.length > limit
|
|
59
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows
|
|
60
|
+
|
|
61
|
+
const nextCursor = hasMore
|
|
62
|
+
? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
|
|
63
|
+
: null
|
|
64
|
+
|
|
65
|
+
if (pageRows.length === 0) {
|
|
66
|
+
return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const items = pageRows.map(row => mapFtsRow(row))
|
|
70
|
+
|
|
71
|
+
return c.json({ items, nextCursor, total } satisfies SearchResponse)
|
|
72
|
+
})
|
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
import { Context } from 'hono'
|
|
2
|
-
|
|
3
|
-
export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
|
|
4
|
-
export type EntityType = 'content' | 'media'
|
|
5
|
-
|
|
6
|
-
export interface ActivityLogParams {
|
|
7
|
-
action: ActivityAction
|
|
8
|
-
entityType: EntityType
|
|
9
|
-
entityId: string
|
|
10
|
-
entitySlug?: string
|
|
11
|
-
details?: Record<string, any>
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Logga un'azione utente asincronamente nel database D1.
|
|
16
|
-
* Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
|
|
17
|
-
*/
|
|
18
|
-
export function logActivity(
|
|
19
|
-
c: Context<any>,
|
|
20
|
-
params: ActivityLogParams
|
|
21
|
-
): void {
|
|
22
|
-
const db = c.env.DB as D1Database
|
|
23
|
-
const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
|
|
24
|
-
|
|
25
|
-
if (!db || !user) return
|
|
26
|
-
|
|
27
|
-
const { action, entityType, entityId, entitySlug, details } = params
|
|
28
|
-
const id = crypto.randomUUID()
|
|
29
|
-
|
|
30
|
-
// Usa waitUntil per non bloccare la risposta al client
|
|
31
|
-
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
32
|
-
try {
|
|
33
|
-
executionCtx = c.executionCtx
|
|
34
|
-
} catch {
|
|
35
|
-
// In ambiente di test Hono lancia se non presente
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (executionCtx) {
|
|
39
|
-
executionCtx.waitUntil((async () => {
|
|
40
|
-
try {
|
|
41
|
-
await db.prepare(
|
|
42
|
-
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
43
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
44
|
-
).bind(
|
|
45
|
-
id,
|
|
46
|
-
user.sub,
|
|
47
|
-
user.email || 'unknown',
|
|
48
|
-
user.name || null,
|
|
49
|
-
action,
|
|
50
|
-
entityType,
|
|
51
|
-
entityId,
|
|
52
|
-
entitySlug || null,
|
|
53
|
-
details ? JSON.stringify(details) : null
|
|
54
|
-
).run()
|
|
55
|
-
} catch (err) {
|
|
56
|
-
console.error('Failed to log activity:', err)
|
|
57
|
-
}
|
|
58
|
-
})())
|
|
59
|
-
} else {
|
|
60
|
-
try {
|
|
61
|
-
db.prepare(
|
|
62
|
-
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
63
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
64
|
-
).bind(
|
|
65
|
-
id,
|
|
66
|
-
user.sub,
|
|
67
|
-
user.email || 'unknown',
|
|
68
|
-
user.name || null,
|
|
69
|
-
action,
|
|
70
|
-
entityType,
|
|
71
|
-
entityId,
|
|
72
|
-
entitySlug || null,
|
|
73
|
-
details ? JSON.stringify(details) : null
|
|
74
|
-
).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
|
|
75
|
-
} catch (err) {
|
|
76
|
-
console.error('Failed to log activity (sync fallback):', err)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export type ActivityAction = 'create' | 'update' | 'delete' | 'upload'
|
|
4
|
+
export type EntityType = 'content' | 'media'
|
|
5
|
+
|
|
6
|
+
export interface ActivityLogParams {
|
|
7
|
+
action: ActivityAction
|
|
8
|
+
entityType: EntityType
|
|
9
|
+
entityId: string
|
|
10
|
+
entitySlug?: string
|
|
11
|
+
details?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Logga un'azione utente asincronamente nel database D1.
|
|
16
|
+
* Estrae le info dell'utente dal JWT payload presente nel contesto Hono.
|
|
17
|
+
*/
|
|
18
|
+
export function logActivity(
|
|
19
|
+
c: Context<any>,
|
|
20
|
+
params: ActivityLogParams
|
|
21
|
+
): void {
|
|
22
|
+
const db = c.env.DB as D1Database
|
|
23
|
+
const user = c.get('jwtPayload') as { sub: string; email: string; name?: string } | undefined
|
|
24
|
+
|
|
25
|
+
if (!db || !user) return
|
|
26
|
+
|
|
27
|
+
const { action, entityType, entityId, entitySlug, details } = params
|
|
28
|
+
const id = crypto.randomUUID()
|
|
29
|
+
|
|
30
|
+
// Usa waitUntil per non bloccare la risposta al client
|
|
31
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
32
|
+
try {
|
|
33
|
+
executionCtx = c.executionCtx
|
|
34
|
+
} catch {
|
|
35
|
+
// In ambiente di test Hono lancia se non presente
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (executionCtx) {
|
|
39
|
+
executionCtx.waitUntil((async () => {
|
|
40
|
+
try {
|
|
41
|
+
await db.prepare(
|
|
42
|
+
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
44
|
+
).bind(
|
|
45
|
+
id,
|
|
46
|
+
user.sub,
|
|
47
|
+
user.email || 'unknown',
|
|
48
|
+
user.name || null,
|
|
49
|
+
action,
|
|
50
|
+
entityType,
|
|
51
|
+
entityId,
|
|
52
|
+
entitySlug || null,
|
|
53
|
+
details ? JSON.stringify(details) : null
|
|
54
|
+
).run()
|
|
55
|
+
} catch (err) {
|
|
56
|
+
console.error('Failed to log activity:', err)
|
|
57
|
+
}
|
|
58
|
+
})())
|
|
59
|
+
} else {
|
|
60
|
+
try {
|
|
61
|
+
db.prepare(
|
|
62
|
+
`INSERT INTO activity_logs (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
63
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
64
|
+
).bind(
|
|
65
|
+
id,
|
|
66
|
+
user.sub,
|
|
67
|
+
user.email || 'unknown',
|
|
68
|
+
user.name || null,
|
|
69
|
+
action,
|
|
70
|
+
entityType,
|
|
71
|
+
entityId,
|
|
72
|
+
entitySlug || null,
|
|
73
|
+
details ? JSON.stringify(details) : null
|
|
74
|
+
).run().catch(err => console.error('Failed to log activity (sync fallback):', err))
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error('Failed to log activity (sync fallback):', err)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { resolvePolicies, sha256hex } from '@beechcms/core'
|
|
3
|
-
import type { Seed } from '@beechcms/core'
|
|
4
|
-
|
|
5
|
-
class PrivacyPolicyError extends Error {
|
|
6
|
-
readonly status = 501 as const
|
|
7
|
-
constructor(message: string) {
|
|
8
|
-
super(message)
|
|
9
|
-
this.name = 'PrivacyPolicyError'
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export { PrivacyPolicyError }
|
|
14
|
-
|
|
15
|
-
/** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
|
|
16
|
-
export async function applyPrivacy(
|
|
17
|
-
data: Record<string, unknown>,
|
|
18
|
-
seed: Seed,
|
|
19
|
-
): Promise<Record<string, unknown>> {
|
|
20
|
-
const result: Record<string, unknown> = {}
|
|
21
|
-
for (const [alias, value] of Object.entries(data)) {
|
|
22
|
-
const branch = seed.branches.find((b) => b.alias === alias)
|
|
23
|
-
if (!branch) {
|
|
24
|
-
result[alias] = value
|
|
25
|
-
continue
|
|
26
|
-
}
|
|
27
|
-
const { privacy } = resolvePolicies(branch)
|
|
28
|
-
if (privacy === 'encrypt') {
|
|
29
|
-
throw new PrivacyPolicyError(
|
|
30
|
-
`Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
|
|
31
|
-
)
|
|
32
|
-
}
|
|
33
|
-
if (privacy === 'hash' && value != null) {
|
|
34
|
-
result[alias] = await sha256hex(String(value))
|
|
35
|
-
} else {
|
|
36
|
-
result[alias] = value
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return result
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Applica la visibility policy ai campi del payload in uscita verso il client. */
|
|
43
|
-
export function applyVisibility(
|
|
44
|
-
data: Record<string, unknown>,
|
|
45
|
-
seed: Seed,
|
|
46
|
-
): Record<string, unknown> {
|
|
47
|
-
const result: Record<string, unknown> = {}
|
|
48
|
-
for (const [alias, value] of Object.entries(data)) {
|
|
49
|
-
const branch = seed.branches.find((b) => b.alias === alias)
|
|
50
|
-
if (!branch) {
|
|
51
|
-
result[alias] = value
|
|
52
|
-
continue
|
|
53
|
-
}
|
|
54
|
-
const { visibility } = resolvePolicies(branch)
|
|
55
|
-
if (visibility === 'hidden') continue
|
|
56
|
-
if (visibility === 'masked') {
|
|
57
|
-
result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
|
|
58
|
-
} else {
|
|
59
|
-
result[alias] = value
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return result
|
|
63
|
-
}
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { resolvePolicies, sha256hex } from '@beechcms/core'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
|
|
5
|
+
class PrivacyPolicyError extends Error {
|
|
6
|
+
readonly status = 501 as const
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'PrivacyPolicyError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { PrivacyPolicyError }
|
|
14
|
+
|
|
15
|
+
/** Applica la privacy policy ai campi del payload prima della scrittura su DB. */
|
|
16
|
+
export async function applyPrivacy(
|
|
17
|
+
data: Record<string, unknown>,
|
|
18
|
+
seed: Seed,
|
|
19
|
+
): Promise<Record<string, unknown>> {
|
|
20
|
+
const result: Record<string, unknown> = {}
|
|
21
|
+
for (const [alias, value] of Object.entries(data)) {
|
|
22
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
23
|
+
if (!branch) {
|
|
24
|
+
result[alias] = value
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
const { privacy } = resolvePolicies(branch)
|
|
28
|
+
if (privacy === 'encrypt') {
|
|
29
|
+
throw new PrivacyPolicyError(
|
|
30
|
+
`Field '${alias}' uses 'encrypt' privacy which is not yet implemented.`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
if (privacy === 'hash' && value != null) {
|
|
34
|
+
result[alias] = await sha256hex(String(value))
|
|
35
|
+
} else {
|
|
36
|
+
result[alias] = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Applica la visibility policy ai campi del payload in uscita verso il client. */
|
|
43
|
+
export function applyVisibility(
|
|
44
|
+
data: Record<string, unknown>,
|
|
45
|
+
seed: Seed,
|
|
46
|
+
): Record<string, unknown> {
|
|
47
|
+
const result: Record<string, unknown> = {}
|
|
48
|
+
for (const [alias, value] of Object.entries(data)) {
|
|
49
|
+
const branch = seed.branches.find((b) => b.alias === alias)
|
|
50
|
+
if (!branch) {
|
|
51
|
+
result[alias] = value
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
const { visibility } = resolvePolicies(branch)
|
|
55
|
+
if (visibility === 'hidden') continue
|
|
56
|
+
if (visibility === 'masked') {
|
|
57
|
+
result[alias] = typeof value === 'string' && value.length > 0 ? '••••••••' : null
|
|
58
|
+
} else {
|
|
59
|
+
result[alias] = value
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return result
|
|
63
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { RepositoryError } from '@beechcms/core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for D1-backed repositories.
|
|
6
|
+
* Handles the D1 instance and provides common database utilities and error mapping.
|
|
7
|
+
*/
|
|
8
|
+
export abstract class BaseD1Repository {
|
|
9
|
+
constructor(protected readonly database: D1Database) {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Utility method to extract a table name for a given seed.
|
|
13
|
+
*/
|
|
14
|
+
protected getTableName(slug: string, isDraft = false): string {
|
|
15
|
+
return isDraft ? `content_${slug}_drafts` : `content_${slug}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Helper to map generic D1 errors to Beech RepositoryErrors.
|
|
20
|
+
* This centralizes error handling for all D1 repositories.
|
|
21
|
+
*/
|
|
22
|
+
protected mapError(error: any, context: string): RepositoryError {
|
|
23
|
+
const message = error?.message || 'Unknown database error'
|
|
24
|
+
// In the future, we can add more specific SQLite error code checks here
|
|
25
|
+
// (e.g., checking for UNIQUE constraint via string matching or codes if available)
|
|
26
|
+
return new RepositoryError(`${context}: ${message}`, error)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,108 +1,82 @@
|
|
|
1
|
-
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import { deserializeFromDb, serializeForDb } from '@beechcms/core'
|
|
3
|
-
import type { Seed } from '@beechcms/core'
|
|
4
|
-
import type { ContentEntry } from './query-utils'
|
|
5
|
-
|
|
6
|
-
/** Deserializza ogni branch colonna di un DB row in formato API alias. */
|
|
7
|
-
export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
|
|
8
|
-
const data: Record<string, unknown> = {}
|
|
9
|
-
for (const branch of seed.branches) {
|
|
10
|
-
data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
|
|
11
|
-
}
|
|
12
|
-
return data
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
|
|
16
|
-
export function rowToEntry(
|
|
17
|
-
seed: Seed,
|
|
18
|
-
row: Record<string, unknown>,
|
|
19
|
-
hasPendingDraft = false
|
|
20
|
-
): ContentEntry {
|
|
21
|
-
return {
|
|
22
|
-
id: row.id as string,
|
|
23
|
-
schema_slug: seed.slug,
|
|
24
|
-
slug: (row.slug as string | null) ?? null,
|
|
25
|
-
status: (row.status as string) ?? 'draft',
|
|
26
|
-
data: rowToApiData(seed, row),
|
|
27
|
-
hasPendingDraft,
|
|
28
|
-
created_at: (row.created_at as number | null) ?? null,
|
|
29
|
-
updated_at: (row.updated_at as number | null) ?? null,
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export interface InsertBindings {
|
|
34
|
-
cols: string[]
|
|
35
|
-
placeholders: string[]
|
|
36
|
-
bindings: (string | number | null)[]
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
|
|
40
|
-
export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
|
|
41
|
-
const cols: string[] = []
|
|
42
|
-
const placeholders: string[] = []
|
|
43
|
-
const bindings: (string | number | null)[] = []
|
|
44
|
-
for (const branch of seed.branches) {
|
|
45
|
-
if (Object.hasOwn(payload, branch.alias)) {
|
|
46
|
-
cols.push(branch.alias)
|
|
47
|
-
placeholders.push('?')
|
|
48
|
-
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
return { cols, placeholders, bindings }
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface UpdateBindings {
|
|
55
|
-
setClause: string
|
|
56
|
-
bindings: (string | number | null)[]
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Costruisce SET clause e bindings per UPDATE content_{slug}. */
|
|
60
|
-
export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
|
|
61
|
-
const setParts: string[] = []
|
|
62
|
-
const bindings: (string | number | null)[] = []
|
|
63
|
-
for (const branch of seed.branches) {
|
|
64
|
-
if (Object.hasOwn(payload, branch.alias)) {
|
|
65
|
-
setParts.push(`${branch.alias} = ?`)
|
|
66
|
-
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
return { setClause: setParts.join(', '), bindings }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Controlla se esiste un draft pendente in content_{slug}_drafts. */
|
|
73
|
-
export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
|
|
74
|
-
if (!seed.allowDrafts) return false
|
|
75
|
-
const row = await db
|
|
76
|
-
.prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
|
|
77
|
-
.bind(entryId)
|
|
78
|
-
.first()
|
|
79
|
-
return row !== null
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export async function logContentEvent(
|
|
84
|
-
db: D1Database,
|
|
85
|
-
opts: {
|
|
86
|
-
action: 'create' | 'update' | 'delete'
|
|
87
|
-
schemaSlug: string
|
|
88
|
-
entryId: string
|
|
89
|
-
userId?: string | null
|
|
90
|
-
details?: Record<string, unknown>
|
|
91
|
-
}
|
|
92
|
-
): Promise<void> {
|
|
93
|
-
await db
|
|
94
|
-
.prepare(
|
|
95
|
-
`INSERT INTO content_event_log (id, schema_slug, entry_id, action, user_id, details, created_at)
|
|
96
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
97
|
-
)
|
|
98
|
-
.bind(
|
|
99
|
-
crypto.randomUUID(),
|
|
100
|
-
opts.schemaSlug,
|
|
101
|
-
opts.entryId,
|
|
102
|
-
opts.action,
|
|
103
|
-
opts.userId ?? null,
|
|
104
|
-
opts.details ? JSON.stringify(opts.details) : null,
|
|
105
|
-
Math.floor(Date.now() / 1000)
|
|
106
|
-
)
|
|
107
|
-
.run()
|
|
108
|
-
}
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { deserializeFromDb, serializeForDb } from '@beechcms/core'
|
|
3
|
+
import type { Seed } from '@beechcms/core'
|
|
4
|
+
import type { ContentEntry } from './query-utils'
|
|
5
|
+
|
|
6
|
+
/** Deserializza ogni branch colonna di un DB row in formato API alias. */
|
|
7
|
+
export function rowToApiData(seed: Seed, row: Record<string, unknown>): Record<string, unknown> {
|
|
8
|
+
const data: Record<string, unknown> = {}
|
|
9
|
+
for (const branch of seed.branches) {
|
|
10
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
|
|
11
|
+
}
|
|
12
|
+
return data
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Converte un row della tabella content_{slug} in ContentEntry per le risposte API. */
|
|
16
|
+
export function rowToEntry(
|
|
17
|
+
seed: Seed,
|
|
18
|
+
row: Record<string, unknown>,
|
|
19
|
+
hasPendingDraft = false
|
|
20
|
+
): ContentEntry {
|
|
21
|
+
return {
|
|
22
|
+
id: row.id as string,
|
|
23
|
+
schema_slug: seed.slug,
|
|
24
|
+
slug: (row.slug as string | null) ?? null,
|
|
25
|
+
status: (row.status as string) ?? 'draft',
|
|
26
|
+
data: rowToApiData(seed, row),
|
|
27
|
+
hasPendingDraft,
|
|
28
|
+
created_at: (row.created_at as number | null) ?? null,
|
|
29
|
+
updated_at: (row.updated_at as number | null) ?? null,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InsertBindings {
|
|
34
|
+
cols: string[]
|
|
35
|
+
placeholders: string[]
|
|
36
|
+
bindings: (string | number | null)[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Costruisce colonne, placeholders e bindings per INSERT INTO content_{slug}. */
|
|
40
|
+
export function buildInsertBindings(seed: Seed, payload: Record<string, unknown>): InsertBindings {
|
|
41
|
+
const cols: string[] = []
|
|
42
|
+
const placeholders: string[] = []
|
|
43
|
+
const bindings: (string | number | null)[] = []
|
|
44
|
+
for (const branch of seed.branches) {
|
|
45
|
+
if (Object.hasOwn(payload, branch.alias)) {
|
|
46
|
+
cols.push(branch.alias)
|
|
47
|
+
placeholders.push('?')
|
|
48
|
+
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { cols, placeholders, bindings }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface UpdateBindings {
|
|
55
|
+
setClause: string
|
|
56
|
+
bindings: (string | number | null)[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Costruisce SET clause e bindings per UPDATE content_{slug}. */
|
|
60
|
+
export function buildUpdateBindings(seed: Seed, payload: Record<string, unknown>): UpdateBindings {
|
|
61
|
+
const setParts: string[] = []
|
|
62
|
+
const bindings: (string | number | null)[] = []
|
|
63
|
+
for (const branch of seed.branches) {
|
|
64
|
+
if (Object.hasOwn(payload, branch.alias)) {
|
|
65
|
+
setParts.push(`${branch.alias} = ?`)
|
|
66
|
+
bindings.push(serializeForDb(branch, payload[branch.alias]))
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { setClause: setParts.join(', '), bindings }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Controlla se esiste un draft pendente in content_{slug}_drafts. */
|
|
73
|
+
export async function hasDraft(db: D1Database, seed: Seed, entryId: string): Promise<boolean> {
|
|
74
|
+
if (!seed.allowDrafts) return false
|
|
75
|
+
const row = await db
|
|
76
|
+
.prepare(`SELECT 1 FROM content_${seed.slug}_drafts WHERE entry_id = ? LIMIT 1`)
|
|
77
|
+
.bind(entryId)
|
|
78
|
+
.first()
|
|
79
|
+
return row !== null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|