@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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import type { Env, Variables } from '../../types'
|
|
5
|
+
|
|
6
|
+
const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
7
|
+
|
|
8
|
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
9
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
10
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
11
|
+
const BCRYPT_ROUNDS = 10
|
|
12
|
+
|
|
13
|
+
type UserRow = {
|
|
14
|
+
id: string
|
|
15
|
+
email: string
|
|
16
|
+
name: string | null
|
|
17
|
+
avatar_url: string | null
|
|
18
|
+
password_hash: string
|
|
19
|
+
notification_prefs: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type SessionRow = {
|
|
23
|
+
id: string
|
|
24
|
+
created_at: number
|
|
25
|
+
expires_at: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ActivityRow = {
|
|
29
|
+
id: string
|
|
30
|
+
action: string
|
|
31
|
+
entity_type: string
|
|
32
|
+
entity_slug: string | null
|
|
33
|
+
details: string | null
|
|
34
|
+
created_at: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type OrphanRow = {
|
|
38
|
+
key: string
|
|
39
|
+
filename: string
|
|
40
|
+
mime_type: string
|
|
41
|
+
size_bytes: number
|
|
42
|
+
created_at: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// GET /api/settings/me
|
|
47
|
+
settingsApp.get('/me', async (c) => {
|
|
48
|
+
const { sub } = c.get('jwtPayload')
|
|
49
|
+
const user = await c.env.DB.prepare(
|
|
50
|
+
'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
51
|
+
).bind(sub).first<Omit<UserRow, 'password_hash'>>()
|
|
52
|
+
if (!user) return c.json({ error: 'User not found' }, 404)
|
|
53
|
+
|
|
54
|
+
let notificationPrefs: Record<string, boolean>
|
|
55
|
+
try { notificationPrefs = JSON.parse(user.notification_prefs || '{}') } catch { notificationPrefs = {} }
|
|
56
|
+
|
|
57
|
+
return c.json({
|
|
58
|
+
id: user.id,
|
|
59
|
+
email: user.email,
|
|
60
|
+
name: user.name,
|
|
61
|
+
avatarUrl: user.avatar_url,
|
|
62
|
+
notificationPrefs: {
|
|
63
|
+
contentCreate: notificationPrefs.contentCreate ?? true,
|
|
64
|
+
contentUpdate: notificationPrefs.contentUpdate ?? true,
|
|
65
|
+
contentDelete: notificationPrefs.contentDelete ?? true,
|
|
66
|
+
mediaUpload: notificationPrefs.mediaUpload ?? false,
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// PUT /api/settings/profile
|
|
72
|
+
settingsApp.put('/profile', async (c) => {
|
|
73
|
+
const { sub } = c.get('jwtPayload')
|
|
74
|
+
let body: Record<string, unknown>
|
|
75
|
+
try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
|
|
76
|
+
|
|
77
|
+
const name = typeof body.name === 'string' ? body.name.trim() : null
|
|
78
|
+
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : null
|
|
79
|
+
|
|
80
|
+
if (email !== null && !EMAIL_REGEX.test(email)) {
|
|
81
|
+
return c.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Formato email non valido' }, 400)
|
|
82
|
+
}
|
|
83
|
+
if (name !== null && name.length > 100) {
|
|
84
|
+
return c.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Nome troppo lungo (max 100 caratteri)' }, 400)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const updates: string[] = []
|
|
88
|
+
const values: unknown[] = []
|
|
89
|
+
if (name !== null) { updates.push('name = ?'); values.push(name) }
|
|
90
|
+
if (email !== null) { updates.push('email = ?'); values.push(email) }
|
|
91
|
+
if (updates.length === 0) return c.json({ error: 'No fields to update' }, 400)
|
|
92
|
+
|
|
93
|
+
if (email !== null) {
|
|
94
|
+
const existing = await c.env.DB.prepare(
|
|
95
|
+
'SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1'
|
|
96
|
+
).bind(email, sub).first()
|
|
97
|
+
if (existing) return c.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email già in uso' }, 409)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
values.push(sub)
|
|
101
|
+
await c.env.DB.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).bind(...values).run()
|
|
102
|
+
return c.json({ success: true })
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// PUT /api/settings/password
|
|
106
|
+
settingsApp.put('/password', async (c) => {
|
|
107
|
+
const { sub } = c.get('jwtPayload')
|
|
108
|
+
let body: Record<string, unknown>
|
|
109
|
+
try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
|
|
110
|
+
|
|
111
|
+
const currentPassword = typeof body.currentPassword === 'string' ? body.currentPassword : ''
|
|
112
|
+
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : ''
|
|
113
|
+
|
|
114
|
+
if (!currentPassword || !newPassword) return c.json({ error: 'currentPassword e newPassword obbligatori' }, 400)
|
|
115
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) return c.json({ error: `La password deve essere di almeno ${MIN_PASSWORD_LENGTH} caratteri` }, 400)
|
|
116
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) return c.json({ error: 'Password troppo lunga' }, 400)
|
|
117
|
+
|
|
118
|
+
const user = await c.env.DB.prepare('SELECT password_hash FROM users WHERE id = ? LIMIT 1').bind(sub).first<{ password_hash: string }>()
|
|
119
|
+
if (!user) return c.json({ error: 'User not found' }, 404)
|
|
120
|
+
|
|
121
|
+
const valid = await bcrypt.compare(currentPassword, user.password_hash)
|
|
122
|
+
if (!valid) return c.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Password attuale non corretta' }, 401)
|
|
123
|
+
|
|
124
|
+
const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
|
|
125
|
+
await c.env.DB.prepare('UPDATE users SET password_hash = ? WHERE id = ?').bind(newHash, sub).run()
|
|
126
|
+
return c.json({ success: true })
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// PUT /api/settings/avatar
|
|
130
|
+
settingsApp.put('/avatar', async (c) => {
|
|
131
|
+
const { sub } = c.get('jwtPayload')
|
|
132
|
+
let body: Record<string, unknown>
|
|
133
|
+
try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
|
|
134
|
+
|
|
135
|
+
const avatarUrl = typeof body.avatarUrl === 'string' ? body.avatarUrl.trim() : null
|
|
136
|
+
await c.env.DB.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').bind(avatarUrl, sub).run()
|
|
137
|
+
return c.json({ success: true })
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// GET /api/settings/sessions
|
|
141
|
+
settingsApp.get('/sessions', async (c) => {
|
|
142
|
+
const { sub } = c.get('jwtPayload')
|
|
143
|
+
const now = Math.floor(Date.now() / 1000)
|
|
144
|
+
const result = await c.env.DB.prepare(
|
|
145
|
+
`SELECT id, created_at, expires_at FROM refresh_tokens
|
|
146
|
+
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
|
|
147
|
+
ORDER BY created_at DESC LIMIT 20`
|
|
148
|
+
).bind(sub, now).all<SessionRow>()
|
|
149
|
+
return c.json(result.results ?? [])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// DELETE /api/settings/sessions/:id
|
|
153
|
+
settingsApp.delete('/sessions/:id', async (c) => {
|
|
154
|
+
const { sub } = c.get('jwtPayload')
|
|
155
|
+
const sessionId = c.req.param('id')
|
|
156
|
+
const now = Math.floor(Date.now() / 1000)
|
|
157
|
+
const result = await c.env.DB.prepare(
|
|
158
|
+
`UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
|
|
159
|
+
).bind(now, sessionId, sub).run()
|
|
160
|
+
const changes = (result as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
|
|
161
|
+
if (changes === 0) return c.json({ error: 'Sessione non trovata o già revocata' }, 404)
|
|
162
|
+
return c.json({ success: true })
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// GET /api/settings/activity
|
|
166
|
+
settingsApp.get('/activity', async (c) => {
|
|
167
|
+
const { sub } = c.get('jwtPayload')
|
|
168
|
+
const result = await c.env.DB.prepare(
|
|
169
|
+
`SELECT id, action, entity_type, entity_slug, details, created_at
|
|
170
|
+
FROM activity_logs WHERE user_id = ?
|
|
171
|
+
ORDER BY created_at DESC LIMIT 30`
|
|
172
|
+
).bind(sub).all<ActivityRow>()
|
|
173
|
+
return c.json(result.results ?? [])
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// GET /api/settings/storage
|
|
177
|
+
settingsApp.get('/storage', async (c) => {
|
|
178
|
+
const stats = await c.env.DB.prepare(
|
|
179
|
+
'SELECT COUNT(*) as file_count, COALESCE(SUM(size_bytes), 0) as total_bytes FROM media_objects'
|
|
180
|
+
).first<{ file_count: number; total_bytes: number }>()
|
|
181
|
+
|
|
182
|
+
// Collect all media keys referenced in file-type columns across all seeds
|
|
183
|
+
const referencedKeys = new Set<string>()
|
|
184
|
+
const seeds = Object.values(c.get('seedRegistry'))
|
|
185
|
+
for (const seed of seeds) {
|
|
186
|
+
const fileBranches = seed.branches.filter(b => b.type === 'file')
|
|
187
|
+
if (fileBranches.length === 0) continue
|
|
188
|
+
const cols = fileBranches.map(b => b.alias).join(', ')
|
|
189
|
+
const rows = await c.env.DB.prepare(
|
|
190
|
+
`SELECT ${cols} FROM content_${seed.slug}`
|
|
191
|
+
).all<Record<string, string | null>>()
|
|
192
|
+
for (const row of rows.results ?? []) {
|
|
193
|
+
const combined = Object.values(row).filter(Boolean).join(' ')
|
|
194
|
+
for (const match of combined.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
|
|
195
|
+
referencedKeys.add(decodeURIComponent(match[1]))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const allMediaRows = await c.env.DB.prepare(
|
|
201
|
+
'SELECT key, filename, mime_type, size_bytes, created_at FROM media_objects ORDER BY created_at DESC LIMIT 50'
|
|
202
|
+
).all<OrphanRow>()
|
|
203
|
+
const orphanResults = (allMediaRows.results ?? []).filter(m => !referencedKeys.has(m.key))
|
|
204
|
+
const orphans = { results: orphanResults }
|
|
205
|
+
|
|
206
|
+
return c.json({
|
|
207
|
+
totalBytes: stats?.total_bytes ?? 0,
|
|
208
|
+
fileCount: stats?.file_count ?? 0,
|
|
209
|
+
orphans: orphans.results ?? [],
|
|
210
|
+
})
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// GET /api/settings/notifications
|
|
214
|
+
settingsApp.get('/notifications', async (c) => {
|
|
215
|
+
const { sub } = c.get('jwtPayload')
|
|
216
|
+
const user = await c.env.DB.prepare(
|
|
217
|
+
'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
218
|
+
).bind(sub).first<{ notification_prefs: string }>()
|
|
219
|
+
if (!user) return c.json({ error: 'User not found' }, 404)
|
|
220
|
+
|
|
221
|
+
let prefs: Record<string, boolean>
|
|
222
|
+
try { prefs = JSON.parse(user.notification_prefs || '{}') } catch { prefs = {} }
|
|
223
|
+
|
|
224
|
+
return c.json({
|
|
225
|
+
contentCreate: prefs.contentCreate ?? true,
|
|
226
|
+
contentUpdate: prefs.contentUpdate ?? true,
|
|
227
|
+
contentDelete: prefs.contentDelete ?? true,
|
|
228
|
+
mediaUpload: prefs.mediaUpload ?? false,
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// PUT /api/settings/notifications
|
|
233
|
+
settingsApp.put('/notifications', async (c) => {
|
|
234
|
+
const { sub } = c.get('jwtPayload')
|
|
235
|
+
let body: Record<string, unknown>
|
|
236
|
+
try { body = await c.req.json() } catch { return c.json({ error: 'Invalid JSON' }, 400) }
|
|
237
|
+
|
|
238
|
+
const prefs = {
|
|
239
|
+
contentCreate: body.contentCreate === true,
|
|
240
|
+
contentUpdate: body.contentUpdate === true,
|
|
241
|
+
contentDelete: body.contentDelete === true,
|
|
242
|
+
mediaUpload: body.mediaUpload === true,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await c.env.DB.prepare('UPDATE users SET notification_prefs = ? WHERE id = ?').bind(JSON.stringify(prefs), sub).run()
|
|
246
|
+
return c.json({ success: true })
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
export { settingsApp }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
import { Hono } from 'hono'
|
|
3
|
+
import bcrypt from 'bcryptjs'
|
|
4
|
+
import type { Env, Variables } from '../../types'
|
|
5
|
+
|
|
6
|
+
export const setupApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
7
|
+
|
|
8
|
+
/** GET /auth/setup — tells the dashboard whether first-run setup is needed */
|
|
9
|
+
setupApp.get('/auth/setup', async (c) => {
|
|
10
|
+
const row = await c.env.DB
|
|
11
|
+
.prepare('SELECT COUNT(*) as count FROM users')
|
|
12
|
+
.first<{ count: number }>()
|
|
13
|
+
return c.json({ needsSetup: (row?.count ?? 0) === 0 })
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** POST /auth/setup — creates the first admin; rejected once any user exists */
|
|
17
|
+
setupApp.post('/auth/setup', async (c) => {
|
|
18
|
+
const row = await c.env.DB
|
|
19
|
+
.prepare('SELECT COUNT(*) as count FROM users')
|
|
20
|
+
.first<{ count: number }>()
|
|
21
|
+
|
|
22
|
+
if ((row?.count ?? 0) > 0) {
|
|
23
|
+
return c.json(
|
|
24
|
+
{ type: 'https://beech.local/errors/setup-already-done', title: 'Setup already completed', status: 403 },
|
|
25
|
+
403
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let body: unknown
|
|
30
|
+
try { body = await c.req.json() } catch {
|
|
31
|
+
return c.json({ type: 'https://beech.local/errors/bad-request', title: 'Invalid JSON body', status: 400 }, 400)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!body || typeof body !== 'object') {
|
|
35
|
+
return c.json({ type: 'https://beech.local/errors/bad-request', title: 'Invalid request', status: 400 }, 400)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { email, password, name } = body as Record<string, unknown>
|
|
39
|
+
|
|
40
|
+
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
|
41
|
+
return c.json({ type: 'https://beech.local/errors/validation', title: 'Valid email required', status: 422 }, 422)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 128) {
|
|
45
|
+
return c.json({ type: 'https://beech.local/errors/validation', title: 'Password must be 8–128 characters', status: 422 }, 422)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const passwordHash = await bcrypt.hash(password, 12)
|
|
49
|
+
const id = crypto.randomUUID()
|
|
50
|
+
const cleanEmail = email.trim().toLowerCase()
|
|
51
|
+
const cleanName = typeof name === 'string' ? name.trim() : null
|
|
52
|
+
|
|
53
|
+
await c.env.DB
|
|
54
|
+
.prepare('INSERT INTO users (id, email, password_hash, role, name) VALUES (?, ?, ?, ?, ?)')
|
|
55
|
+
.bind(id, cleanEmail, passwordHash, 'admin', cleanName)
|
|
56
|
+
.run()
|
|
57
|
+
|
|
58
|
+
return c.json({ success: true }, 201)
|
|
59
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { statsApp } from './stats.handler'
|