@beechcms/api 0.4.0-preview.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/README.md +21 -0
- package/migrations/0000_v040_base.sql +213 -0
- package/package.json +36 -0
- package/src/auth/constants.ts +10 -0
- package/src/auth/login.ts +91 -0
- package/src/auth/refresh.ts +127 -0
- package/src/content.ts +502 -0
- package/src/factory.ts +56 -0
- package/src/features/draft/draft.handler.ts +198 -0
- package/src/features/draft/draft.test.ts +315 -0
- package/src/features/draft/index.ts +1 -0
- package/src/features/email/email.provider.ts +38 -0
- package/src/features/email/email.service.ts +80 -0
- package/src/features/email/email.types.ts +98 -0
- package/src/features/email/index.ts +28 -0
- package/src/features/email/providers/resend.ts +63 -0
- package/src/features/email/templates/password-changed.ts +59 -0
- package/src/features/email/templates/password-reset.ts +64 -0
- package/src/features/email/templates/shell.ts +93 -0
- package/src/features/notifications/index.ts +1 -0
- package/src/features/notifications/notifications.handler.ts +88 -0
- package/src/features/password-reset/index.ts +15 -0
- package/src/features/password-reset/request.ts +88 -0
- package/src/features/password-reset/reset.ts +110 -0
- package/src/features/rotate-field/index.ts +1 -0
- package/src/features/rotate-field/rotate-field.handler.ts +82 -0
- package/src/features/rotate-field/rotate-field.schema.ts +9 -0
- package/src/features/rotate-field/rotate-field.test.ts +297 -0
- package/src/features/settings/settings.handler.ts +249 -0
- package/src/features/setup/index.ts +59 -0
- package/src/features/stats/index.ts +1 -0
- package/src/features/stats/stats.handler.ts +395 -0
- package/src/index.ts +344 -0
- package/src/media-utils.ts +78 -0
- package/src/middleware.ts +67 -0
- package/src/public/access-policy.ts +23 -0
- package/src/public/api-key-middleware.ts +53 -0
- package/src/public/index.ts +12 -0
- package/src/public/problem-details.ts +42 -0
- package/src/public/public-add.ts +183 -0
- package/src/public/public-edit.ts +183 -0
- package/src/public/public-errors.ts +15 -0
- package/src/public/public-read.ts +217 -0
- package/src/public/public-routes.ts +31 -0
- package/src/public/query-builder.ts +241 -0
- package/src/public/rate-limit-middleware.ts +42 -0
- package/src/public/response-builder.ts +26 -0
- package/src/public/sanitize.ts +65 -0
- package/src/public/slug-utils.ts +14 -0
- package/src/search-utils.ts +188 -0
- package/src/search.ts +72 -0
- package/src/shared/activity-logger.ts +79 -0
- package/src/shared/apply-policies.ts +63 -0
- package/src/shared/content-utils.ts +108 -0
- package/src/shared/fts-sync.ts +4 -0
- package/src/shared/notification-service.ts +56 -0
- package/src/shared/query-utils.ts +137 -0
- package/src/shared/storage-utils.ts +36 -0
- package/src/types.ts +35 -0
- package/src/upload.ts +335 -0
- package/src/widget.ts +349 -0
package/src/upload.ts
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Engine: upload e servizio file da Cloudflare R2.
|
|
3
|
+
*
|
|
4
|
+
* Usa l'API S3-compatibile (@aws-sdk/client-s3) con chiavi di accesso per
|
|
5
|
+
* portabilità e configurabilità. Le credenziali vanno in .dev.vars (locale)
|
|
6
|
+
* o wrangler secret (produzione).
|
|
7
|
+
*
|
|
8
|
+
* @see docs/media-engine.md
|
|
9
|
+
*/
|
|
10
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
11
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'
|
|
12
|
+
import { Hono } from 'hono'
|
|
13
|
+
import { authMiddleware } from './middleware'
|
|
14
|
+
import { logActivity } from './shared/activity-logger'
|
|
15
|
+
|
|
16
|
+
/** Variabili d'ambiente per upload e media (R2 via S3 API) */
|
|
17
|
+
type UploadBindings = {
|
|
18
|
+
JWT_SECRET: string
|
|
19
|
+
MEDIA_BASE_URL?: string
|
|
20
|
+
ENV?: string
|
|
21
|
+
R2_ACCESS_KEY_ID?: string
|
|
22
|
+
R2_SECRET_ACCESS_KEY?: string
|
|
23
|
+
R2_ENDPOINT?: string
|
|
24
|
+
R2_BUCKET_NAME?: string
|
|
25
|
+
DB: D1Database
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Variables = {
|
|
29
|
+
jwtPayload: { sub: string; email?: string }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Prefissi MIME consentiti (immagini e PDF) */
|
|
33
|
+
const ALLOWED_MIME_PREFIXES = ['image/', 'application/pdf']
|
|
34
|
+
|
|
35
|
+
/** Dimensione massima file: 5 MB */
|
|
36
|
+
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
|
37
|
+
|
|
38
|
+
type FileLike = {
|
|
39
|
+
name: string
|
|
40
|
+
type: string
|
|
41
|
+
size: number
|
|
42
|
+
arrayBuffer: () => Promise<ArrayBuffer>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isFileLike(value: unknown): value is FileLike {
|
|
46
|
+
if (!value || typeof value === 'string') return false
|
|
47
|
+
const v = value as Record<string, unknown>
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
typeof v.name === 'string' &&
|
|
51
|
+
typeof v.type === 'string' &&
|
|
52
|
+
typeof v.size === 'number' &&
|
|
53
|
+
typeof v.arrayBuffer === 'function'
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Sanitizza il nome file: rimuove caratteri non sicuri, mantiene estensione */
|
|
58
|
+
function sanitizeFilename(name: string): string {
|
|
59
|
+
const base = name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 100)
|
|
60
|
+
return base || 'file'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Genera chiave univoca per R2 (timestamp-sanitized-name) */
|
|
64
|
+
function generateObjectKey(originalName: string): string {
|
|
65
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
66
|
+
const sanitized = sanitizeFilename(originalName)
|
|
67
|
+
return `${timestamp}-${sanitized}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Restituisce l'URL base per costruire gli URL pubblici dei media */
|
|
71
|
+
function getMediaBaseUrl(c: { req: { url: string }; env: UploadBindings }): string {
|
|
72
|
+
const base = c.env.MEDIA_BASE_URL?.trim()
|
|
73
|
+
if (base) return base.replace(/\/$/, '')
|
|
74
|
+
return new URL(c.req.url).origin
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Crea client S3 per R2 */
|
|
78
|
+
export function createR2Client(env: UploadBindings): S3Client {
|
|
79
|
+
if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.R2_ENDPOINT) {
|
|
80
|
+
throw new Error('R2 credentials not configured')
|
|
81
|
+
}
|
|
82
|
+
return new S3Client({
|
|
83
|
+
region: 'auto',
|
|
84
|
+
endpoint: env.R2_ENDPOINT,
|
|
85
|
+
credentials: {
|
|
86
|
+
accessKeyId: env.R2_ACCESS_KEY_ID!,
|
|
87
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY!,
|
|
88
|
+
},
|
|
89
|
+
forcePathStyle: true,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Env minimale per delete R2 (solo R2_* e ENV) */
|
|
94
|
+
export type R2DeleteEnv = Pick<
|
|
95
|
+
UploadBindings,
|
|
96
|
+
'R2_ACCESS_KEY_ID' | 'R2_SECRET_ACCESS_KEY' | 'R2_ENDPOINT' | 'R2_BUCKET_NAME' | 'ENV' | 'DB'
|
|
97
|
+
>
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Elimina oggetti da R2 per le chiavi date.
|
|
101
|
+
* Usato alla cancellazione entry per rimuovere i file associati da R2.
|
|
102
|
+
*/
|
|
103
|
+
export async function deleteR2Objects(
|
|
104
|
+
env: R2DeleteEnv,
|
|
105
|
+
objectKeys: string[]
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const isR2Configured =
|
|
108
|
+
env.R2_ACCESS_KEY_ID &&
|
|
109
|
+
env.R2_SECRET_ACCESS_KEY &&
|
|
110
|
+
env.R2_ENDPOINT &&
|
|
111
|
+
env.R2_BUCKET_NAME
|
|
112
|
+
|
|
113
|
+
if (!isR2Configured || objectKeys.length === 0) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const s3Client = createR2Client(env as UploadBindings)
|
|
118
|
+
for (const objectKey of objectKeys) {
|
|
119
|
+
try {
|
|
120
|
+
// Ottieni la dimensione prima della cancellazione per aggiornare il contatore
|
|
121
|
+
let fileSize = 0
|
|
122
|
+
try {
|
|
123
|
+
const head = await s3Client.send(
|
|
124
|
+
new HeadObjectCommand({
|
|
125
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
126
|
+
Key: objectKey,
|
|
127
|
+
})
|
|
128
|
+
)
|
|
129
|
+
fileSize = head.ContentLength ?? 0
|
|
130
|
+
} catch (headErr) {
|
|
131
|
+
// Se il file non esiste già su R2, fileSize resta 0
|
|
132
|
+
if (env.ENV !== 'production') {
|
|
133
|
+
console.warn('R2 head failed for key (skip size update)', objectKey, headErr)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await s3Client.send(
|
|
138
|
+
new DeleteObjectCommand({
|
|
139
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
140
|
+
Key: objectKey,
|
|
141
|
+
})
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
// Decrementa contatore storage in D1 se abbiamo trovato la dimensione
|
|
145
|
+
if (fileSize > 0) {
|
|
146
|
+
await env.DB.prepare(
|
|
147
|
+
"UPDATE system_stats SET value = MAX(0, CAST(value AS INTEGER) - ?) WHERE id = 'total_storage_bytes'"
|
|
148
|
+
).bind(fileSize).run()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Rimuovi dalla media library
|
|
152
|
+
await env.DB.prepare('DELETE FROM media_objects WHERE key = ?').bind(objectKey).run()
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (env.ENV !== 'production') {
|
|
155
|
+
console.warn('R2 delete failed for key', objectKey, err)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const uploadRoutes = new Hono<{
|
|
162
|
+
Bindings: UploadBindings
|
|
163
|
+
Variables: Variables
|
|
164
|
+
}>()
|
|
165
|
+
|
|
166
|
+
/** POST /upload - Carica file su R2, restituisce URL pubblico */
|
|
167
|
+
uploadRoutes.post('/upload', async (c, next) => {
|
|
168
|
+
await authMiddleware(c.env.JWT_SECRET)(c, next)
|
|
169
|
+
}, async (c) => {
|
|
170
|
+
try {
|
|
171
|
+
const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
|
|
172
|
+
if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
|
|
173
|
+
return c.json({ error: 'R2 not configured. Set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME' }, 500)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const contentType = c.req.header('Content-Type') ?? ''
|
|
177
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
178
|
+
return c.json({ error: 'Content-Type must be multipart/form-data' }, 400)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const formData = await c.req.formData()
|
|
182
|
+
const fileEntry = formData.get('file')
|
|
183
|
+
// In Cloudflare/Workers il value può essere `string` o un oggetto (File/Blob-like).
|
|
184
|
+
// Usiamo un guard sulle proprietà richieste.
|
|
185
|
+
if (!isFileLike(fileEntry)) {
|
|
186
|
+
return c.json({ error: 'No file provided. Use field name "file"' }, 400)
|
|
187
|
+
}
|
|
188
|
+
const file = fileEntry
|
|
189
|
+
|
|
190
|
+
const mimeOk = ALLOWED_MIME_PREFIXES.some((prefix) => file.type.startsWith(prefix))
|
|
191
|
+
if (!mimeOk) {
|
|
192
|
+
return c.json(
|
|
193
|
+
{ error: 'File type not allowed. Allowed: images and PDF' },
|
|
194
|
+
400
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
199
|
+
return c.json({ error: 'File too large. Max 5MB' }, 400)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const objectKey = generateObjectKey(file.name)
|
|
203
|
+
const client = createR2Client(c.env)
|
|
204
|
+
|
|
205
|
+
const body = await file.arrayBuffer()
|
|
206
|
+
await client.send(
|
|
207
|
+
new PutObjectCommand({
|
|
208
|
+
Bucket: R2_BUCKET_NAME,
|
|
209
|
+
Key: objectKey,
|
|
210
|
+
Body: new Uint8Array(body),
|
|
211
|
+
ContentType: file.type,
|
|
212
|
+
})
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
// Aggiorna contatore storage in D1
|
|
216
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
217
|
+
try {
|
|
218
|
+
executionCtx = c.executionCtx
|
|
219
|
+
} catch {
|
|
220
|
+
// In ambiente di test Hono lancia se non presente
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const uploadedBy = c.var.jwtPayload?.sub ?? ''
|
|
224
|
+
if (executionCtx) {
|
|
225
|
+
executionCtx.waitUntil((async () => {
|
|
226
|
+
try {
|
|
227
|
+
await c.env.DB.prepare(
|
|
228
|
+
"UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
|
|
229
|
+
).bind(file.size).run()
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('Failed to update storage stats on upload:', err)
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
await c.env.DB.prepare(
|
|
235
|
+
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
236
|
+
).bind(objectKey, file.name, file.type, file.size, uploadedBy).run()
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error('Failed to track media_objects on upload:', err)
|
|
239
|
+
}
|
|
240
|
+
})())
|
|
241
|
+
} else {
|
|
242
|
+
// Fallback sync
|
|
243
|
+
c.env.DB.prepare(
|
|
244
|
+
"UPDATE system_stats SET value = CAST(value AS INTEGER) + ? WHERE id = 'total_storage_bytes'"
|
|
245
|
+
).bind(file.size).run().catch(err => console.error('Failed to update storage stats on upload (sync fallback):', err))
|
|
246
|
+
c.env.DB.prepare(
|
|
247
|
+
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
248
|
+
).bind(objectKey, file.name, file.type, file.size, uploadedBy).run().catch(err => console.error('Failed to track media_objects (sync fallback):', err))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const baseUrl = getMediaBaseUrl(c)
|
|
252
|
+
const publicUrl = `${baseUrl}/api/media/${encodeURIComponent(objectKey)}`
|
|
253
|
+
|
|
254
|
+
logActivity(c, {
|
|
255
|
+
action: 'upload',
|
|
256
|
+
entityType: 'media',
|
|
257
|
+
entityId: objectKey,
|
|
258
|
+
details: { name: file.name, size: file.size, type: file.type }
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
return c.json({ url: publicUrl }, 200)
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (c.env.ENV !== 'production') {
|
|
264
|
+
console.error('Upload error:', err)
|
|
265
|
+
}
|
|
266
|
+
return c.json({ error: 'Upload failed' }, 500)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
/** DELETE /upload/:key - Elimina un file da R2 */
|
|
271
|
+
uploadRoutes.delete('/:key', async (c, next) => {
|
|
272
|
+
await authMiddleware(c.env.JWT_SECRET)(c, next)
|
|
273
|
+
}, async (c) => {
|
|
274
|
+
const key = c.req.param('key')
|
|
275
|
+
if (!key) return c.json({ error: 'Missing key' }, 400)
|
|
276
|
+
|
|
277
|
+
await deleteR2Objects(c.env, [decodeURIComponent(key)])
|
|
278
|
+
return c.json({ success: true }, 200)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Serve un file da R2. Route pubblica (senza auth) per permettere
|
|
283
|
+
* il caricamento delle immagini nei tag <img>.
|
|
284
|
+
*/
|
|
285
|
+
export async function serveMediaHandler(
|
|
286
|
+
c: { env: UploadBindings; req: { param: (key: string) => string } }
|
|
287
|
+
): Promise<Response> {
|
|
288
|
+
const key = c.req.param('key')
|
|
289
|
+
if (!key) {
|
|
290
|
+
return new Response(JSON.stringify({ error: 'Missing key' }), {
|
|
291
|
+
status: 400,
|
|
292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ENDPOINT, R2_BUCKET_NAME } = c.env
|
|
297
|
+
if (!R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY || !R2_ENDPOINT || !R2_BUCKET_NAME) {
|
|
298
|
+
return new Response(JSON.stringify({ error: 'R2 not configured' }), {
|
|
299
|
+
status: 500,
|
|
300
|
+
headers: { 'Content-Type': 'application/json' },
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try {
|
|
305
|
+
const client = createR2Client(c.env)
|
|
306
|
+
const response = await client.send(
|
|
307
|
+
new GetObjectCommand({
|
|
308
|
+
Bucket: R2_BUCKET_NAME,
|
|
309
|
+
Key: decodeURIComponent(key),
|
|
310
|
+
})
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if (!response.Body) {
|
|
314
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
315
|
+
status: 404,
|
|
316
|
+
headers: { 'Content-Type': 'application/json' },
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const headers = new Headers()
|
|
321
|
+
const ct = response.ContentType ?? 'application/octet-stream'
|
|
322
|
+
headers.set('Content-Type', ct)
|
|
323
|
+
headers.set('Cache-Control', 'public, max-age=31536000, immutable')
|
|
324
|
+
|
|
325
|
+
return new Response(response.Body as ReadableStream, {
|
|
326
|
+
status: 200,
|
|
327
|
+
headers,
|
|
328
|
+
})
|
|
329
|
+
} catch {
|
|
330
|
+
return new Response(JSON.stringify({ error: 'Not found' }), {
|
|
331
|
+
status: 404,
|
|
332
|
+
headers: { 'Content-Type': 'application/json' },
|
|
333
|
+
})
|
|
334
|
+
}
|
|
335
|
+
}
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import { deserializeFromDb } from '@beechcms/core'
|
|
4
|
+
import type { Seed } from '@beechcms/core'
|
|
5
|
+
import type { Env, Variables } from './types'
|
|
6
|
+
|
|
7
|
+
const widgetApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
type AggregateFormula =
|
|
12
|
+
| { op: 'count' }
|
|
13
|
+
| { op: 'sum'; column: string }
|
|
14
|
+
| { op: 'avg'; column: string }
|
|
15
|
+
| { op: 'min'; column: string }
|
|
16
|
+
| { op: 'max'; column: string }
|
|
17
|
+
| { op: 'countWhere'; column: string; value: unknown }
|
|
18
|
+
| { op: 'percentageOf'; numeratorColumn: string; denominatorColumn: string }
|
|
19
|
+
|
|
20
|
+
type TimeWindow = 'week' | 'month' | 'year' | 'all'
|
|
21
|
+
|
|
22
|
+
function timeWindowSql(window: TimeWindow): string {
|
|
23
|
+
switch (window) {
|
|
24
|
+
case 'week': return "created_at >= unixepoch('now', '-7 days')"
|
|
25
|
+
case 'month': return "created_at >= unixepoch('now', '-1 month')"
|
|
26
|
+
case 'year': return "created_at >= unixepoch('now', '-1 year')"
|
|
27
|
+
case 'all': return '1=1'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function previousWindowSql(window: TimeWindow): { current: string; previous: string } {
|
|
32
|
+
switch (window) {
|
|
33
|
+
case 'week':
|
|
34
|
+
return {
|
|
35
|
+
current: "created_at >= unixepoch('now', '-7 days')",
|
|
36
|
+
previous: "created_at >= unixepoch('now', '-14 days') AND created_at < unixepoch('now', '-7 days')",
|
|
37
|
+
}
|
|
38
|
+
case 'month':
|
|
39
|
+
return {
|
|
40
|
+
current: "created_at >= unixepoch('now', '-1 month')",
|
|
41
|
+
previous: "created_at >= unixepoch('now', '-2 months') AND created_at < unixepoch('now', '-1 month')",
|
|
42
|
+
}
|
|
43
|
+
case 'year':
|
|
44
|
+
return {
|
|
45
|
+
current: "created_at >= unixepoch('now', '-1 year')",
|
|
46
|
+
previous: "created_at >= unixepoch('now', '-2 years') AND created_at < unixepoch('now', '-1 year')",
|
|
47
|
+
}
|
|
48
|
+
case 'all':
|
|
49
|
+
return { current: '1=1', previous: '1=0' }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SYSTEM_COLUMNS = new Set(['created_at', 'updated_at', 'status', 'id', 'slug'])
|
|
54
|
+
|
|
55
|
+
// In v0.4.0 alias = column name. Validate against seed to prevent injection.
|
|
56
|
+
function resolveColumnExpr(seed: Seed, alias: string): string {
|
|
57
|
+
if (SYSTEM_COLUMNS.has(alias)) return alias
|
|
58
|
+
const branch = seed.branches.find(b => b.alias === alias)
|
|
59
|
+
return branch ? branch.alias : 'id'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildAggregateExpr(seed: Seed, formula: AggregateFormula): string {
|
|
63
|
+
switch (formula.op) {
|
|
64
|
+
case 'count':
|
|
65
|
+
return 'COUNT(*)'
|
|
66
|
+
case 'sum':
|
|
67
|
+
return `SUM(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
68
|
+
case 'avg':
|
|
69
|
+
return `AVG(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
70
|
+
case 'min':
|
|
71
|
+
return `MIN(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
72
|
+
case 'max':
|
|
73
|
+
return `MAX(CAST(${resolveColumnExpr(seed, formula.column)} AS REAL))`
|
|
74
|
+
case 'countWhere': {
|
|
75
|
+
const expr = resolveColumnExpr(seed, formula.column)
|
|
76
|
+
const val = formula.value
|
|
77
|
+
if (val === null) return `COUNT(CASE WHEN ${expr} IS NULL THEN 1 END)`
|
|
78
|
+
if (typeof val === 'boolean') return `COUNT(CASE WHEN ${expr} = ${val ? 1 : 0} THEN 1 END)`
|
|
79
|
+
if (typeof val === 'number') return `COUNT(CASE WHEN CAST(${expr} AS REAL) = ${val} THEN 1 END)`
|
|
80
|
+
return `COUNT(CASE WHEN ${expr} = '${String(val).replace(/'/g, "''")}' THEN 1 END)`
|
|
81
|
+
}
|
|
82
|
+
case 'percentageOf': {
|
|
83
|
+
const num = resolveColumnExpr(seed, formula.numeratorColumn)
|
|
84
|
+
const den = resolveColumnExpr(seed, formula.denominatorColumn)
|
|
85
|
+
return `CASE WHEN SUM(CAST(${den} AS REAL)) = 0 THEN 0 ELSE (SUM(CAST(${num} AS REAL)) * 100.0 / SUM(CAST(${den} AS REAL))) END`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseFormula(raw: string | undefined): AggregateFormula | null {
|
|
91
|
+
if (!raw) return null
|
|
92
|
+
try {
|
|
93
|
+
const parsed = JSON.parse(raw) as unknown
|
|
94
|
+
if (typeof parsed !== 'object' || parsed === null || !('op' in parsed)) return null
|
|
95
|
+
return parsed as AggregateFormula
|
|
96
|
+
} catch {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function parseWindow(raw: string | undefined): TimeWindow {
|
|
102
|
+
if (raw === 'week' || raw === 'month' || raw === 'year' || raw === 'all') return raw
|
|
103
|
+
return 'all'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function error(status: number, title: string, detail: string) {
|
|
107
|
+
return { type: 'about:blank', title, status, detail }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Routes ─────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
widgetApp.get('/aggregate/:seed', async (c) => {
|
|
113
|
+
const seedSlug = c.req.param('seed')
|
|
114
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
115
|
+
if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
116
|
+
|
|
117
|
+
const formula = parseFormula(c.req.query('formula'))
|
|
118
|
+
if (!formula) return c.json(error(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
|
|
119
|
+
|
|
120
|
+
const window = parseWindow(c.req.query('window'))
|
|
121
|
+
const aggExpr = buildAggregateExpr(seed, formula)
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const row = await c.env.DB.prepare(
|
|
125
|
+
`SELECT ${aggExpr} as value FROM content_${seed.slug} WHERE (${timeWindowSql(window)})`
|
|
126
|
+
).first<{ value: number | null }>()
|
|
127
|
+
return c.json({ value: row?.value ?? 0, window })
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error('[widget/aggregate] DB error:', err)
|
|
130
|
+
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
widgetApp.get('/growth/:seed', async (c) => {
|
|
135
|
+
const seedSlug = c.req.param('seed')
|
|
136
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
137
|
+
if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
138
|
+
|
|
139
|
+
const formula = parseFormula(c.req.query('formula'))
|
|
140
|
+
if (!formula) return c.json(error(400, 'Bad Request', 'Invalid or missing formula parameter (must be JSON)'), 400)
|
|
141
|
+
|
|
142
|
+
const window = parseWindow(c.req.query('window'))
|
|
143
|
+
const { current: currentSql, previous: previousSql } = previousWindowSql(window)
|
|
144
|
+
const aggExpr = buildAggregateExpr(seed, formula)
|
|
145
|
+
const table = `content_${seed.slug}`
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const [currentRow, previousRow] = await Promise.all([
|
|
149
|
+
c.env.DB.prepare(`SELECT ${aggExpr} as value FROM ${table} WHERE (${currentSql})`).first<{ value: number | null }>(),
|
|
150
|
+
c.env.DB.prepare(`SELECT ${aggExpr} as value FROM ${table} WHERE (${previousSql})`).first<{ value: number | null }>(),
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
const current = currentRow?.value ?? 0
|
|
154
|
+
const previous = previousRow?.value ?? 0
|
|
155
|
+
|
|
156
|
+
let percentageChange = 0
|
|
157
|
+
let trend: 'up' | 'down' | 'flat' = 'flat'
|
|
158
|
+
|
|
159
|
+
if (previous !== 0) {
|
|
160
|
+
percentageChange = Math.round(((current - previous) / Math.abs(previous)) * 1000) / 10
|
|
161
|
+
} else if (current > 0) {
|
|
162
|
+
percentageChange = 100
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (percentageChange > 0) trend = 'up'
|
|
166
|
+
else if (percentageChange < 0) trend = 'down'
|
|
167
|
+
|
|
168
|
+
return c.json({ current, previous, percentageChange, trend })
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error('[widget/growth] DB error:', err)
|
|
171
|
+
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
widgetApp.get('/leaderboard/:seed', async (c) => {
|
|
176
|
+
const seedSlug = c.req.param('seed')
|
|
177
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
178
|
+
if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
179
|
+
|
|
180
|
+
const scoreColumn = c.req.query('scoreColumn')
|
|
181
|
+
if (!scoreColumn) return c.json(error(400, 'Bad Request', 'Missing scoreColumn parameter'), 400)
|
|
182
|
+
|
|
183
|
+
const limitRaw = parseInt(c.req.query('limit') ?? '10', 10)
|
|
184
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 10
|
|
185
|
+
const orderDir = c.req.query('orderDir') === 'asc' ? 'ASC' : 'DESC'
|
|
186
|
+
const scoreExpr = resolveColumnExpr(seed, scoreColumn)
|
|
187
|
+
const labelCol = resolveColumnExpr(seed, seed.displayNameAlias)
|
|
188
|
+
const table = `content_${seed.slug}`
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const rows = await c.env.DB.prepare(
|
|
192
|
+
`SELECT id, ${labelCol} as label, ${scoreExpr} as score
|
|
193
|
+
FROM ${table}
|
|
194
|
+
WHERE ${scoreExpr} IS NOT NULL
|
|
195
|
+
ORDER BY CAST(${scoreExpr} AS REAL) ${orderDir}
|
|
196
|
+
LIMIT ?`
|
|
197
|
+
).bind(limit).all<{ id: string; label: string | null; score: number | string | null }>()
|
|
198
|
+
|
|
199
|
+
const entries = (rows.results ?? []).map(row => ({
|
|
200
|
+
id: row.id,
|
|
201
|
+
label: row.label ?? row.id,
|
|
202
|
+
score: row.score ?? 0,
|
|
203
|
+
}))
|
|
204
|
+
|
|
205
|
+
return c.json(entries)
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.error('[widget/leaderboard] DB error:', err)
|
|
208
|
+
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
widgetApp.get('/list/:seed', async (c) => {
|
|
213
|
+
const seedSlug = c.req.param('seed')
|
|
214
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
215
|
+
if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
216
|
+
|
|
217
|
+
const { DB } = c.env
|
|
218
|
+
const query = c.req.query()
|
|
219
|
+
const table = `content_${seed.slug}`
|
|
220
|
+
|
|
221
|
+
const limitRaw = parseInt(query.limit ?? '25', 10)
|
|
222
|
+
const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(limitRaw, 100) : 25
|
|
223
|
+
const offsetRaw = parseInt(query.offset ?? '0', 10)
|
|
224
|
+
const offset = Number.isFinite(offsetRaw) && offsetRaw >= 0 ? offsetRaw : 0
|
|
225
|
+
|
|
226
|
+
const search = query.search?.trim() ?? ''
|
|
227
|
+
const displayCol = resolveColumnExpr(seed, seed.displayNameAlias)
|
|
228
|
+
|
|
229
|
+
const conditions: string[] = []
|
|
230
|
+
const bindings: unknown[] = []
|
|
231
|
+
|
|
232
|
+
if (search) {
|
|
233
|
+
conditions.push(`${displayCol} LIKE ?`)
|
|
234
|
+
bindings.push(`%${search}%`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (query.filters) {
|
|
238
|
+
try {
|
|
239
|
+
const rawFilters = JSON.parse(query.filters) as Array<{ column: string; op: string; value: unknown }>
|
|
240
|
+
for (const f of rawFilters) {
|
|
241
|
+
const expr = resolveColumnExpr(seed, f.column)
|
|
242
|
+
switch (f.op) {
|
|
243
|
+
case '=':
|
|
244
|
+
case 'eq': conditions.push(`${expr} = ?`); bindings.push(f.value); break
|
|
245
|
+
case '!=':
|
|
246
|
+
case 'neq': conditions.push(`${expr} != ?`); bindings.push(f.value); break
|
|
247
|
+
case 'like': conditions.push(`${expr} LIKE ?`); bindings.push(f.value); break
|
|
248
|
+
case '>':
|
|
249
|
+
case 'gt': conditions.push(`CAST(${expr} AS REAL) > ?`); bindings.push(f.value); break
|
|
250
|
+
case '<':
|
|
251
|
+
case 'lt': conditions.push(`CAST(${expr} AS REAL) < ?`); bindings.push(f.value); break
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
} catch {
|
|
255
|
+
return c.json(error(400, 'Bad Request', 'Invalid filters JSON'), 400)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const orderByAlias = query.orderBy ?? ''
|
|
260
|
+
const orderDir = query.orderDir === 'desc' ? 'DESC' : 'ASC'
|
|
261
|
+
const orderExpr = orderByAlias ? resolveColumnExpr(seed, orderByAlias) : 'created_at'
|
|
262
|
+
const whereSql = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const [countRow, listRows] = await Promise.all([
|
|
266
|
+
DB.prepare(`SELECT COUNT(*) as total FROM ${table} ${whereSql}`)
|
|
267
|
+
.bind(...bindings)
|
|
268
|
+
.first<{ total: number }>(),
|
|
269
|
+
DB.prepare(
|
|
270
|
+
`SELECT id, slug, status, created_at, updated_at, ${seed.branches.map(b => b.alias).join(', ')}
|
|
271
|
+
FROM ${table} ${whereSql} ORDER BY ${orderExpr} ${orderDir} LIMIT ? OFFSET ?`
|
|
272
|
+
)
|
|
273
|
+
.bind(...bindings, limit, offset)
|
|
274
|
+
.all<Record<string, unknown>>(),
|
|
275
|
+
])
|
|
276
|
+
|
|
277
|
+
const entries = (listRows.results ?? []).map(row => {
|
|
278
|
+
const data: Record<string, unknown> = {}
|
|
279
|
+
for (const branch of seed.branches) {
|
|
280
|
+
data[branch.alias] = deserializeFromDb(branch, row[branch.alias] ?? null)
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
id: row.id as string,
|
|
284
|
+
slug: (row.slug as string | null) ?? '',
|
|
285
|
+
status: row.status as string,
|
|
286
|
+
createdAt: (row.created_at as number) ?? 0,
|
|
287
|
+
updatedAt: (row.updated_at as number) ?? 0,
|
|
288
|
+
...data,
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
return c.json({ entries, total: countRow?.total ?? 0 })
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error('[widget/list] DB error:', err)
|
|
295
|
+
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
widgetApp.get('/timeseries/:seed', async (c) => {
|
|
300
|
+
const seedSlug = c.req.param('seed')
|
|
301
|
+
const seed = c.get('getSeed')(seedSlug)
|
|
302
|
+
if (!seed) return c.json(error(404, 'Not Found', `Seed '${seedSlug}' not found`), 404)
|
|
303
|
+
|
|
304
|
+
const valueColumn = c.req.query('valueColumn')
|
|
305
|
+
const groupColumn = c.req.query('groupColumn') ?? 'created_at'
|
|
306
|
+
const formulaOp = c.req.query('formula') ?? 'count'
|
|
307
|
+
const window = parseWindow(c.req.query('window'))
|
|
308
|
+
const table = `content_${seed.slug}`
|
|
309
|
+
|
|
310
|
+
if (!valueColumn && formulaOp !== 'count') {
|
|
311
|
+
return c.json(error(400, 'Bad Request', 'valueColumn is required when formula is not count'), 400)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const groupExpr = resolveColumnExpr(seed, groupColumn)
|
|
315
|
+
const dateBucketExpr = `strftime('%Y-%m-%d', ${groupExpr === 'created_at' ? groupExpr : `CAST(${groupExpr} AS INTEGER)`}, 'unixepoch')`
|
|
316
|
+
|
|
317
|
+
let aggExpr: string
|
|
318
|
+
if (formulaOp === 'count') {
|
|
319
|
+
aggExpr = 'COUNT(*)'
|
|
320
|
+
} else if (formulaOp === 'sum' && valueColumn) {
|
|
321
|
+
aggExpr = `SUM(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
|
|
322
|
+
} else if (formulaOp === 'avg' && valueColumn) {
|
|
323
|
+
aggExpr = `AVG(CAST(${resolveColumnExpr(seed, valueColumn)} AS REAL))`
|
|
324
|
+
} else {
|
|
325
|
+
return c.json(error(400, 'Bad Request', 'formula must be sum, avg, or count'), 400)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
const rows = await c.env.DB.prepare(
|
|
330
|
+
`SELECT ${dateBucketExpr} as label, ${aggExpr} as value
|
|
331
|
+
FROM ${table}
|
|
332
|
+
WHERE (${timeWindowSql(window)})
|
|
333
|
+
GROUP BY ${dateBucketExpr}
|
|
334
|
+
ORDER BY ${dateBucketExpr} ASC`
|
|
335
|
+
).bind().all<{ label: string | null; value: number | null }>()
|
|
336
|
+
|
|
337
|
+
const points = (rows.results ?? []).map(row => ({
|
|
338
|
+
label: row.label ?? '',
|
|
339
|
+
value: row.value ?? 0,
|
|
340
|
+
}))
|
|
341
|
+
|
|
342
|
+
return c.json({ points })
|
|
343
|
+
} catch (err) {
|
|
344
|
+
console.error('[widget/timeseries] DB error:', err)
|
|
345
|
+
return c.json(error(500, 'Internal Server Error', 'Database error'), 500)
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
export { widgetApp }
|