@beechcms/api 0.4.0 → 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/assets/index-CH13idU1.js +554 -0
- package/assets/dashboard/index.html +1 -1
- package/package.json +2 -2
- package/src/auth/bcrypt-hash-provider.ts +20 -0
- package/src/auth/constants.ts +3 -3
- 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 +15 -32
- package/src/auth/refresh.ts +0 -122
- package/src/auth/static-token-service.ts +18 -0
- package/src/auth/token-service.test.ts +82 -0
- package/src/factory.ts +70 -78
- package/src/features/content/handlers/create.ts +14 -10
- package/src/features/content/handlers/delete.ts +13 -9
- package/src/features/content/handlers/update.ts +13 -9
- package/src/features/draft/draft.handler.ts +23 -12
- package/src/features/notifications/notifications.handler.ts +25 -54
- package/src/features/password-reset/request.ts +17 -41
- package/src/features/password-reset/reset.ts +18 -54
- package/src/features/rotate-field/rotate-field.handler.ts +15 -19
- package/src/features/schema/schema.handler.ts +1 -1
- package/src/features/settings/settings.handler.ts +62 -175
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- 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 +41 -5
- package/src/middleware.ts +15 -35
- package/src/public/public-add.ts +5 -15
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +3 -3
- package/src/public/public-routes.ts +2 -2
- package/src/public/query-builder.test.ts +220 -0
- package/src/public/rate-limit-middleware.ts +7 -19
- 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 +18 -1
- package/src/search.ts +24 -35
- package/src/shared/apply-policies.test.ts +77 -0
- package/src/shared/background-notification-service.test.ts +58 -0
- package/src/shared/background-notification-service.ts +48 -0
- package/src/shared/content-utils.test.ts +161 -0
- package/src/shared/content.repository.d1.test.ts +312 -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/idempotency.repository.d1.test.ts +79 -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 +1 -1
- package/src/shared/request-utils.ts +22 -0
- package/src/shared/sequential-id-generator.ts +22 -0
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +20 -3
- package/src/upload.ts +14 -7
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
- package/src/shared/activity-logger.ts +0 -79
- package/src/shared/notification-service.ts +0 -56
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { D1MediaRepository } from './media.repository.d1'
|
|
3
|
+
|
|
4
|
+
function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
|
|
5
|
+
const { firstResult = null, allResults = [] } = opts
|
|
6
|
+
const runMock = vi.fn().mockResolvedValue({ success: true })
|
|
7
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
8
|
+
const allMock = vi.fn().mockResolvedValue({ results: allResults })
|
|
9
|
+
// bind() returns the same interface so chains like prepare().bind().all() work.
|
|
10
|
+
// The statement also exposes first/all/run directly for queries that skip bind().
|
|
11
|
+
const bindMock = vi.fn(() => ({ run: runMock, first: firstMock, all: allMock }))
|
|
12
|
+
const stmt = { bind: bindMock, run: runMock, first: firstMock, all: allMock }
|
|
13
|
+
const prepareMock = vi.fn(() => stmt)
|
|
14
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('D1MediaRepository', () => {
|
|
18
|
+
describe('trackUpload', () => {
|
|
19
|
+
it('calls INSERT INTO media_objects with correct bound values', async () => {
|
|
20
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
21
|
+
const obj = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 1024, uploaded_by: 'user-1' }
|
|
22
|
+
await new D1MediaRepository(db).trackUpload(obj)
|
|
23
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO media_objects'))
|
|
24
|
+
expect(bindMock).toHaveBeenCalledWith('k1', 'img.png', 'image/png', 1024, 'user-1')
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe('getByKey', () => {
|
|
29
|
+
it('returns the media object when found', async () => {
|
|
30
|
+
const row = { key: 'k1', filename: 'img.png', mime_type: 'image/png', size_bytes: 512, uploaded_by: 'u1', created_at: 1000 }
|
|
31
|
+
const { db } = makeMockDb({ firstResult: row })
|
|
32
|
+
const result = await new D1MediaRepository(db).getByKey('k1')
|
|
33
|
+
expect(result).toEqual(row)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns null when the key is not found', async () => {
|
|
37
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
38
|
+
expect(await new D1MediaRepository(db).getByKey('ghost')).toBeNull()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes the key as bound value', async () => {
|
|
42
|
+
const { db, bindMock } = makeMockDb()
|
|
43
|
+
await new D1MediaRepository(db).getByKey('my-key')
|
|
44
|
+
expect(bindMock).toHaveBeenCalledWith('my-key')
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('untrack', () => {
|
|
49
|
+
it('calls DELETE FROM media_objects with the key', async () => {
|
|
50
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
51
|
+
await new D1MediaRepository(db).untrack('k1')
|
|
52
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('DELETE FROM media_objects'))
|
|
53
|
+
expect(bindMock).toHaveBeenCalledWith('k1')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('list', () => {
|
|
58
|
+
it('returns items and total from D1', async () => {
|
|
59
|
+
const rows = [
|
|
60
|
+
{ key: 'k1', filename: 'a.png', mime_type: 'image/png', size_bytes: 100, uploaded_by: 'u1', created_at: 1000 },
|
|
61
|
+
]
|
|
62
|
+
const { db, allMock, firstMock } = makeMockDb()
|
|
63
|
+
// list() calls all() for items then first() for count (without bind)
|
|
64
|
+
allMock.mockResolvedValueOnce({ results: rows })
|
|
65
|
+
firstMock.mockResolvedValueOnce({ total: 1 })
|
|
66
|
+
|
|
67
|
+
const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
|
|
68
|
+
expect(result.items).toHaveLength(1)
|
|
69
|
+
expect(result.total).toBe(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns empty items and 0 total when D1 returns nothing', async () => {
|
|
73
|
+
const { db, allMock, firstMock } = makeMockDb()
|
|
74
|
+
allMock.mockResolvedValueOnce({ results: [] })
|
|
75
|
+
firstMock.mockResolvedValueOnce(null) // count row is null → 0
|
|
76
|
+
|
|
77
|
+
const result = await new D1MediaRepository(db).list({ limit: 10, offset: 0 })
|
|
78
|
+
expect(result.items).toEqual([])
|
|
79
|
+
expect(result.total).toBe(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('passes limit and offset as bound values for the list query', async () => {
|
|
83
|
+
const { db, bindMock, allMock, firstMock } = makeMockDb()
|
|
84
|
+
allMock.mockResolvedValueOnce({ results: [] })
|
|
85
|
+
firstMock.mockResolvedValueOnce({ total: 0 })
|
|
86
|
+
|
|
87
|
+
await new D1MediaRepository(db).list({ limit: 5, offset: 10 })
|
|
88
|
+
expect(bindMock).toHaveBeenCalledWith(5, 10)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('count', () => {
|
|
93
|
+
it('returns the count from D1', async () => {
|
|
94
|
+
const { db } = makeMockDb({ firstResult: { total: 42 } })
|
|
95
|
+
expect(await new D1MediaRepository(db).count()).toBe(42)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('returns 0 when D1 returns null', async () => {
|
|
99
|
+
const { db } = makeMockDb({ firstResult: null })
|
|
100
|
+
expect(await new D1MediaRepository(db).count()).toBe(0)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -10,7 +10,7 @@ export class D1MediaRepository implements MediaRepository {
|
|
|
10
10
|
/**
|
|
11
11
|
* Registers a new media upload in the database.
|
|
12
12
|
*/
|
|
13
|
-
async trackUpload(mediaObject: MediaObject): Promise<void> {
|
|
13
|
+
async trackUpload(mediaObject: Omit<MediaObject, 'created_at'>): Promise<void> {
|
|
14
14
|
await this.database.prepare(
|
|
15
15
|
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
16
16
|
).bind(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HonoRequest } from "hono"
|
|
2
|
+
|
|
3
|
+
/** The header Cloudflare sets on every incoming request to the Worker. */
|
|
4
|
+
const CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip"
|
|
5
|
+
|
|
6
|
+
/** The fallback value used when the IP header is absent (local dev, unit tests). */
|
|
7
|
+
const UNKNOWN_IP = "unknown"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Extracts the real client IP address from a Cloudflare Worker request.
|
|
11
|
+
*
|
|
12
|
+
* Cloudflare injects the cf-connecting-ip header on every request that
|
|
13
|
+
* passes through the edge. In local development (wrangler dev) or in
|
|
14
|
+
* unit tests the header may be absent, in which case the string "unknown"
|
|
15
|
+
* is returned so that rate-limiter keys remain non-empty and safe to use.
|
|
16
|
+
*
|
|
17
|
+
* Never derive security decisions from this value alone; treat it as a
|
|
18
|
+
* best-effort hint for rate-limiting and logging purposes only.
|
|
19
|
+
*/
|
|
20
|
+
export function getClientIp(request: HonoRequest): string {
|
|
21
|
+
return request.raw.headers.get(CLOUDFLARE_CLIENT_IP_HEADER) ?? UNKNOWN_IP
|
|
22
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { IIdGenerator } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
const ID_PADDING_WIDTH = 4
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Test-only IIdGenerator implementation. Each call returns a stable,
|
|
7
|
+
* monotonically increasing identifier (`test-id-0001`, `test-id-0002`, …)
|
|
8
|
+
* so snapshot assertions and insert-order checks remain deterministic
|
|
9
|
+
* across runs.
|
|
10
|
+
*/
|
|
11
|
+
export class SequentialIdGenerator implements IIdGenerator {
|
|
12
|
+
private counter = 0
|
|
13
|
+
|
|
14
|
+
uuid(): string {
|
|
15
|
+
this.counter += 1
|
|
16
|
+
return `test-id-${String(this.counter).padStart(ID_PADDING_WIDTH, '0')}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
reset(): void {
|
|
20
|
+
this.counter = 0
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { D1SystemStatsRepository } from './system-stats.repository.d1'
|
|
3
|
+
|
|
4
|
+
function makeMockDb(firstResult: unknown = null) {
|
|
5
|
+
const runMock = vi.fn().mockResolvedValue({ success: true })
|
|
6
|
+
const firstMock = vi.fn().mockResolvedValue(firstResult)
|
|
7
|
+
// bind() returns the same statement interface for chained calls.
|
|
8
|
+
// The statement also exposes first() directly for queries without bind (e.g. getStorageUsage).
|
|
9
|
+
const bindMock = vi.fn(() => ({ run: runMock, first: firstMock }))
|
|
10
|
+
const stmt = { bind: bindMock, run: runMock, first: firstMock }
|
|
11
|
+
const prepareMock = vi.fn(() => stmt)
|
|
12
|
+
return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('D1SystemStatsRepository', () => {
|
|
16
|
+
describe('incrementStorage', () => {
|
|
17
|
+
it('calls UPDATE system_stats with the byte count', async () => {
|
|
18
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
19
|
+
await new D1SystemStatsRepository(db).incrementStorage(512)
|
|
20
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
|
|
21
|
+
expect(bindMock).toHaveBeenCalledWith(512)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('decrementStorage', () => {
|
|
26
|
+
it('calls UPDATE system_stats with MAX(0, ...) and the byte count', async () => {
|
|
27
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
28
|
+
await new D1SystemStatsRepository(db).decrementStorage(256)
|
|
29
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('MAX(0'))
|
|
30
|
+
expect(bindMock).toHaveBeenCalledWith(256)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('setStorage', () => {
|
|
35
|
+
it('calls UPDATE system_stats with the stringified byte count', async () => {
|
|
36
|
+
const { db, prepareMock, bindMock } = makeMockDb()
|
|
37
|
+
await new D1SystemStatsRepository(db).setStorage(1024)
|
|
38
|
+
expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE system_stats'))
|
|
39
|
+
expect(bindMock).toHaveBeenCalledWith('1024')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('getStorageUsage', () => {
|
|
44
|
+
it('parses and returns the integer value from D1', async () => {
|
|
45
|
+
const { db } = makeMockDb({ value: '2048' })
|
|
46
|
+
expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(2048)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns 0 when D1 returns null (stat not initialised)', async () => {
|
|
50
|
+
const { db } = makeMockDb(null)
|
|
51
|
+
expect(await new D1SystemStatsRepository(db).getStorageUsage()).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
-
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository } from '@beechcms/core'
|
|
2
|
+
import type { Seed, ContentRepository, IdempotencyRepository, BeechBucket, MediaRepository, SystemStatsRepository, IHashProvider, ITokenService, IUserRepository, ISessionRepository, IPasswordResetTokenRepository, IActivityLogger, IActivityLogRepository, INotificationRepository, INotificationService, IWidgetRepository, ISearchRepository, IAnalyticsRepository, IContentScanRepository, ISeedRegistry, IClock, IIdGenerator } from '@beechcms/core'
|
|
3
|
+
import type { IRateLimiterRegistry } from './middleware/rate-limit.middleware'
|
|
3
4
|
|
|
4
5
|
export interface Env {
|
|
5
6
|
DB: D1Database
|
|
@@ -33,14 +34,30 @@ export interface Env {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface Variables {
|
|
36
|
-
jwtPayload: { sub: string; email?: string }
|
|
37
|
+
jwtPayload: { sub: string; email?: string; name?: string | null }
|
|
37
38
|
getSeed: (slug: string) => Seed | null
|
|
38
|
-
seedRegistry:
|
|
39
|
+
seedRegistry: ISeedRegistry
|
|
39
40
|
repository: ContentRepository
|
|
40
41
|
idempotencyRepository: IdempotencyRepository
|
|
41
42
|
bucket: BeechBucket
|
|
42
43
|
mediaRepository: MediaRepository
|
|
43
44
|
systemStatsRepository: SystemStatsRepository
|
|
45
|
+
hashProvider: IHashProvider
|
|
46
|
+
tokenService: ITokenService
|
|
47
|
+
userRepository: IUserRepository
|
|
48
|
+
sessionRepository: ISessionRepository
|
|
49
|
+
passwordResetTokenRepository: IPasswordResetTokenRepository
|
|
50
|
+
rateLimiters: IRateLimiterRegistry
|
|
51
|
+
activityLogger: IActivityLogger
|
|
52
|
+
activityLogRepository: IActivityLogRepository
|
|
53
|
+
notificationRepository: INotificationRepository
|
|
54
|
+
notificationService: INotificationService
|
|
55
|
+
widgetRepository: IWidgetRepository
|
|
56
|
+
searchRepository: ISearchRepository
|
|
57
|
+
analyticsRepository: IAnalyticsRepository
|
|
58
|
+
contentScanRepository: IContentScanRepository
|
|
59
|
+
clock: IClock
|
|
60
|
+
idGenerator: IIdGenerator
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
export type AppEnv = { Bindings: Env; Variables: Variables }
|
package/src/upload.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* e facilità di sviluppo locale.
|
|
6
6
|
*/
|
|
7
7
|
import { Hono } from 'hono'
|
|
8
|
-
import { logActivity } from './shared/activity-logger'
|
|
9
8
|
import { AppEnv } from './types'
|
|
10
9
|
|
|
11
10
|
/** Prefissi MIME consentiti (immagini e PDF) */
|
|
@@ -133,12 +132,20 @@ uploadRoutes.post('/upload', async (c) => {
|
|
|
133
132
|
|
|
134
133
|
const publicUrl = bucket.getUrl(objectKey)
|
|
135
134
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
const jwtPayload = c.get('jwtPayload')
|
|
136
|
+
if (jwtPayload) {
|
|
137
|
+
c.get('activityLogger').log({
|
|
138
|
+
action: 'upload',
|
|
139
|
+
entityType: 'media',
|
|
140
|
+
entityId: objectKey,
|
|
141
|
+
details: { name: file.name, size: file.size, type: file.type },
|
|
142
|
+
actor: {
|
|
143
|
+
id: jwtPayload.sub,
|
|
144
|
+
email: jwtPayload.email ?? 'unknown',
|
|
145
|
+
name: jwtPayload.name ?? null,
|
|
146
|
+
},
|
|
147
|
+
})
|
|
148
|
+
}
|
|
142
149
|
|
|
143
150
|
return c.json({ url: publicUrl }, 200)
|
|
144
151
|
} catch (err) {
|