@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
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
|
-
import bcrypt from 'bcryptjs'
|
|
4
3
|
import type { Env, Variables } from '../../types'
|
|
5
4
|
|
|
6
5
|
const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
@@ -8,48 +7,14 @@ const settingsApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
|
8
7
|
const EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
9
8
|
const MIN_PASSWORD_LENGTH = 8
|
|
10
9
|
const MAX_PASSWORD_LENGTH = 128
|
|
11
|
-
const
|
|
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 MediaFileRow = {
|
|
38
|
-
key: string
|
|
39
|
-
filename: string
|
|
40
|
-
mime_type: string
|
|
41
|
-
size_bytes: number
|
|
42
|
-
created_at: number
|
|
43
|
-
}
|
|
44
|
-
|
|
10
|
+
const SESSION_LIST_LIMIT = 20
|
|
11
|
+
const ACTIVITY_LOG_LIMIT = 30
|
|
45
12
|
|
|
46
13
|
/**
|
|
47
14
|
* GET /api/settings
|
|
48
15
|
* Retrieves the general site configuration.
|
|
49
16
|
*/
|
|
50
17
|
settingsApp.get('/', async (context) => {
|
|
51
|
-
// General site configuration.
|
|
52
|
-
// In the future, these could be loaded from a 'system_settings' table in D1.
|
|
53
18
|
return context.json({
|
|
54
19
|
siteTitle: 'Beech CMS',
|
|
55
20
|
siteLogo: '/beechLogoDark.svg',
|
|
@@ -70,18 +35,15 @@ settingsApp.get('/', async (context) => {
|
|
|
70
35
|
*/
|
|
71
36
|
settingsApp.get('/me', async (context) => {
|
|
72
37
|
const { sub: userId } = context.get('jwtPayload')
|
|
73
|
-
|
|
74
|
-
const currentUser = await context.
|
|
75
|
-
'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
76
|
-
).bind(userId).first<Omit<UserRow, 'password_hash'>>()
|
|
77
|
-
|
|
38
|
+
|
|
39
|
+
const currentUser = await context.get('userRepository').findById(userId)
|
|
78
40
|
if (!currentUser) {
|
|
79
41
|
return context.json({ error: 'User not found' }, 404)
|
|
80
42
|
}
|
|
81
43
|
|
|
82
44
|
let notificationPreferences: Record<string, boolean>
|
|
83
45
|
try {
|
|
84
|
-
notificationPreferences = JSON.parse(currentUser.
|
|
46
|
+
notificationPreferences = JSON.parse(currentUser.notificationPreferences || '{}')
|
|
85
47
|
} catch {
|
|
86
48
|
notificationPreferences = {}
|
|
87
49
|
}
|
|
@@ -90,7 +52,7 @@ settingsApp.get('/me', async (context) => {
|
|
|
90
52
|
id: currentUser.id,
|
|
91
53
|
email: currentUser.email,
|
|
92
54
|
name: currentUser.name,
|
|
93
|
-
avatarUrl: currentUser.
|
|
55
|
+
avatarUrl: currentUser.avatarUrl,
|
|
94
56
|
notificationPrefs: {
|
|
95
57
|
contentCreate: notificationPreferences.contentCreate ?? true,
|
|
96
58
|
contentUpdate: notificationPreferences.contentUpdate ?? true,
|
|
@@ -106,7 +68,7 @@ settingsApp.get('/me', async (context) => {
|
|
|
106
68
|
*/
|
|
107
69
|
settingsApp.put('/profile', async (context) => {
|
|
108
70
|
const { sub: userId } = context.get('jwtPayload')
|
|
109
|
-
|
|
71
|
+
|
|
110
72
|
let payload: Record<string, unknown>
|
|
111
73
|
try {
|
|
112
74
|
payload = await context.req.json()
|
|
@@ -118,61 +80,30 @@ settingsApp.put('/profile', async (context) => {
|
|
|
118
80
|
const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
|
|
119
81
|
|
|
120
82
|
if (emailInput !== null && !EMAIL_VALIDATION_REGEX.test(emailInput)) {
|
|
121
|
-
return context.json({
|
|
122
|
-
type: 'bad-request',
|
|
123
|
-
title: 'Bad Request',
|
|
124
|
-
status: 400,
|
|
125
|
-
detail: 'Invalid email format'
|
|
126
|
-
}, 400)
|
|
83
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid email format' }, 400)
|
|
127
84
|
}
|
|
128
|
-
|
|
85
|
+
|
|
129
86
|
if (nameInput !== null && nameInput.length > 100) {
|
|
130
|
-
return context.json({
|
|
131
|
-
type: 'bad-request',
|
|
132
|
-
title: 'Bad Request',
|
|
133
|
-
status: 400,
|
|
134
|
-
detail: 'Name is too long (maximum 100 characters)'
|
|
135
|
-
}, 400)
|
|
87
|
+
return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
|
|
136
88
|
}
|
|
137
89
|
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (nameInput !== null) {
|
|
142
|
-
fieldsToUpdate.push('name = ?')
|
|
143
|
-
valuesToUpdate.push(nameInput)
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (emailInput !== null) {
|
|
147
|
-
fieldsToUpdate.push('email = ?')
|
|
148
|
-
valuesToUpdate.push(emailInput)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (fieldsToUpdate.length === 0) {
|
|
90
|
+
const hasNoFields = nameInput === null && emailInput === null
|
|
91
|
+
if (hasNoFields) {
|
|
152
92
|
return context.json({ error: 'No fields to update' }, 400)
|
|
153
93
|
}
|
|
154
94
|
|
|
155
|
-
// Check if the new email is already taken by another user
|
|
156
95
|
if (emailInput !== null) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (existingUserWithEmail) {
|
|
162
|
-
return context.json({
|
|
163
|
-
type: 'conflict',
|
|
164
|
-
title: 'Conflict',
|
|
165
|
-
status: 409,
|
|
166
|
-
detail: 'Email address is already in use'
|
|
167
|
-
}, 409)
|
|
96
|
+
const emailTaken = await context.get('userRepository').emailBelongsToAnotherUser(emailInput, userId)
|
|
97
|
+
if (emailTaken) {
|
|
98
|
+
return context.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email address is already in use' }, 409)
|
|
168
99
|
}
|
|
169
100
|
}
|
|
170
101
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
102
|
+
const fieldsToUpdate: { name?: string; email?: string } = {}
|
|
103
|
+
if (nameInput !== null) fieldsToUpdate.name = nameInput
|
|
104
|
+
if (emailInput !== null) fieldsToUpdate.email = emailInput
|
|
105
|
+
|
|
106
|
+
await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
|
|
176
107
|
return context.json({ success: true })
|
|
177
108
|
})
|
|
178
109
|
|
|
@@ -182,7 +113,7 @@ settingsApp.put('/profile', async (context) => {
|
|
|
182
113
|
*/
|
|
183
114
|
settingsApp.put('/password', async (context) => {
|
|
184
115
|
const { sub: userId } = context.get('jwtPayload')
|
|
185
|
-
|
|
116
|
+
|
|
186
117
|
let payload: Record<string, unknown>
|
|
187
118
|
try {
|
|
188
119
|
payload = await context.req.json()
|
|
@@ -196,38 +127,26 @@ settingsApp.put('/password', async (context) => {
|
|
|
196
127
|
if (!currentPassword || !newPassword) {
|
|
197
128
|
return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
|
|
198
129
|
}
|
|
199
|
-
|
|
200
130
|
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
201
131
|
return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
|
|
202
132
|
}
|
|
203
|
-
|
|
204
133
|
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
205
134
|
return context.json({ error: 'Password is too long' }, 400)
|
|
206
135
|
}
|
|
207
136
|
|
|
208
|
-
const userRecord = await context.
|
|
209
|
-
'SELECT password_hash FROM users WHERE id = ? LIMIT 1'
|
|
210
|
-
).bind(userId).first<{ password_hash: string }>()
|
|
211
|
-
|
|
137
|
+
const userRecord = await context.get('userRepository').findById(userId)
|
|
212
138
|
if (!userRecord) {
|
|
213
139
|
return context.json({ error: 'User not found' }, 404)
|
|
214
140
|
}
|
|
215
141
|
|
|
216
|
-
const
|
|
142
|
+
const hashProvider = context.get('hashProvider')
|
|
143
|
+
const isPasswordCorrect = await hashProvider.verify(currentPassword, userRecord.passwordHash)
|
|
217
144
|
if (!isPasswordCorrect) {
|
|
218
|
-
return context.json({
|
|
219
|
-
type: 'invalid-credentials',
|
|
220
|
-
title: 'Unauthorized',
|
|
221
|
-
status: 401,
|
|
222
|
-
detail: 'Current password is incorrect'
|
|
223
|
-
}, 401)
|
|
145
|
+
return context.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Current password is incorrect' }, 401)
|
|
224
146
|
}
|
|
225
147
|
|
|
226
|
-
const hashedNewPassword = await
|
|
227
|
-
await context.
|
|
228
|
-
'UPDATE users SET password_hash = ? WHERE id = ?'
|
|
229
|
-
).bind(hashedNewPassword, userId).run()
|
|
230
|
-
|
|
148
|
+
const hashedNewPassword = await hashProvider.hash(newPassword)
|
|
149
|
+
await context.get('userRepository').updatePasswordHash(userId, hashedNewPassword)
|
|
231
150
|
return context.json({ success: true })
|
|
232
151
|
})
|
|
233
152
|
|
|
@@ -237,7 +156,7 @@ settingsApp.put('/password', async (context) => {
|
|
|
237
156
|
*/
|
|
238
157
|
settingsApp.put('/avatar', async (context) => {
|
|
239
158
|
const { sub: userId } = context.get('jwtPayload')
|
|
240
|
-
|
|
159
|
+
|
|
241
160
|
let payload: Record<string, unknown>
|
|
242
161
|
try {
|
|
243
162
|
payload = await context.req.json()
|
|
@@ -246,11 +165,7 @@ settingsApp.put('/avatar', async (context) => {
|
|
|
246
165
|
}
|
|
247
166
|
|
|
248
167
|
const avatarUrl = typeof payload.avatarUrl === 'string' ? payload.avatarUrl.trim() : null
|
|
249
|
-
|
|
250
|
-
await context.env.DB.prepare(
|
|
251
|
-
'UPDATE users SET avatar_url = ? WHERE id = ?'
|
|
252
|
-
).bind(avatarUrl, userId).run()
|
|
253
|
-
|
|
168
|
+
await context.get('userRepository').updateAvatarUrl(userId, avatarUrl)
|
|
254
169
|
return context.json({ success: true })
|
|
255
170
|
})
|
|
256
171
|
|
|
@@ -260,15 +175,9 @@ settingsApp.put('/avatar', async (context) => {
|
|
|
260
175
|
*/
|
|
261
176
|
settingsApp.get('/sessions', async (context) => {
|
|
262
177
|
const { sub: userId } = context.get('jwtPayload')
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
`SELECT id, created_at, expires_at FROM refresh_tokens
|
|
267
|
-
WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ?
|
|
268
|
-
ORDER BY created_at DESC LIMIT 20`
|
|
269
|
-
).bind(userId, currentTimestamp).all<SessionRow>()
|
|
270
|
-
|
|
271
|
-
return context.json(sessionsResult.results ?? [])
|
|
178
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
179
|
+
const sessions = await context.get('sessionRepository').listActiveForUser(userId, nowTimestamp, SESSION_LIST_LIMIT)
|
|
180
|
+
return context.json(sessions)
|
|
272
181
|
})
|
|
273
182
|
|
|
274
183
|
/**
|
|
@@ -278,18 +187,13 @@ settingsApp.get('/sessions', async (context) => {
|
|
|
278
187
|
settingsApp.delete('/sessions/:id', async (context) => {
|
|
279
188
|
const { sub: userId } = context.get('jwtPayload')
|
|
280
189
|
const sessionId = context.req.param('id')
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
).bind(currentTimestamp, sessionId, userId).run()
|
|
286
|
-
|
|
287
|
-
const affectedRowsCount = (dbUpdateResult as unknown as { meta?: { changes?: number } })?.meta?.changes ?? 0
|
|
288
|
-
|
|
289
|
-
if (affectedRowsCount === 0) {
|
|
190
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
191
|
+
|
|
192
|
+
const wasRevoked = await context.get('sessionRepository').revokeById(sessionId, userId, nowTimestamp)
|
|
193
|
+
if (!wasRevoked) {
|
|
290
194
|
return context.json({ error: 'Session not found or already revoked' }, 404)
|
|
291
195
|
}
|
|
292
|
-
|
|
196
|
+
|
|
293
197
|
return context.json({ success: true })
|
|
294
198
|
})
|
|
295
199
|
|
|
@@ -299,14 +203,23 @@ settingsApp.delete('/sessions/:id', async (context) => {
|
|
|
299
203
|
*/
|
|
300
204
|
settingsApp.get('/activity', async (context) => {
|
|
301
205
|
const { sub: userId } = context.get('jwtPayload')
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
206
|
+
|
|
207
|
+
const entries = await context.get('activityLogRepository').list({
|
|
208
|
+
userId,
|
|
209
|
+
limit: ACTIVITY_LOG_LIMIT,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Preserve legacy snake_case shape consumed by the dashboard activity tab.
|
|
213
|
+
const responseEntries = entries.map((entry) => ({
|
|
214
|
+
id: entry.id,
|
|
215
|
+
action: entry.action,
|
|
216
|
+
entity_type: entry.entityType,
|
|
217
|
+
entity_slug: entry.entitySlug,
|
|
218
|
+
details: entry.details ? JSON.stringify(entry.details) : null,
|
|
219
|
+
created_at: entry.createdAt,
|
|
220
|
+
}))
|
|
221
|
+
|
|
222
|
+
return context.json(responseEntries)
|
|
310
223
|
})
|
|
311
224
|
|
|
312
225
|
/**
|
|
@@ -316,31 +229,12 @@ settingsApp.get('/activity', async (context) => {
|
|
|
316
229
|
settingsApp.get('/storage', async (context) => {
|
|
317
230
|
const mediaRepo = context.get('mediaRepository')
|
|
318
231
|
const statsRepo = context.get('systemStatsRepository')
|
|
319
|
-
|
|
232
|
+
|
|
320
233
|
const totalStorageUsedBytes = await statsRepo.getStorageUsage()
|
|
321
234
|
const totalFileCount = await mediaRepo.count()
|
|
322
235
|
|
|
323
|
-
|
|
324
|
-
const referencedMediaKeys =
|
|
325
|
-
const registeredSeeds = Object.values(context.get('seedRegistry'))
|
|
326
|
-
|
|
327
|
-
for (const seed of registeredSeeds) {
|
|
328
|
-
const mediaFields = seed.branches.filter(branch => branch.type === 'file')
|
|
329
|
-
if (mediaFields.length === 0) continue
|
|
330
|
-
|
|
331
|
-
const mediaColumns = mediaFields.map(field => field.alias).join(', ')
|
|
332
|
-
const contentData = await context.env.DB.prepare(
|
|
333
|
-
`SELECT ${mediaColumns} FROM content_${seed.slug}`
|
|
334
|
-
).all<Record<string, string | null>>()
|
|
335
|
-
|
|
336
|
-
for (const contentRow of contentData.results ?? []) {
|
|
337
|
-
const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
|
|
338
|
-
// Simple regex to find media keys in stored URLs or strings
|
|
339
|
-
for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
|
|
340
|
-
referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
236
|
+
const registeredSeeds = context.get('seedRegistry').all()
|
|
237
|
+
const referencedMediaKeys = await context.get('contentScanRepository').getReferencedMediaKeys(registeredSeeds)
|
|
344
238
|
|
|
345
239
|
const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
|
|
346
240
|
const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
|
|
@@ -358,18 +252,15 @@ settingsApp.get('/storage', async (context) => {
|
|
|
358
252
|
*/
|
|
359
253
|
settingsApp.get('/notifications', async (context) => {
|
|
360
254
|
const { sub: userId } = context.get('jwtPayload')
|
|
361
|
-
|
|
362
|
-
const userRecord = await context.
|
|
363
|
-
'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
364
|
-
).bind(userId).first<{ notification_prefs: string }>()
|
|
365
|
-
|
|
255
|
+
|
|
256
|
+
const userRecord = await context.get('userRepository').findById(userId)
|
|
366
257
|
if (!userRecord) {
|
|
367
258
|
return context.json({ error: 'User not found' }, 404)
|
|
368
259
|
}
|
|
369
260
|
|
|
370
261
|
let userPreferences: Record<string, boolean>
|
|
371
262
|
try {
|
|
372
|
-
userPreferences = JSON.parse(userRecord.
|
|
263
|
+
userPreferences = JSON.parse(userRecord.notificationPreferences || '{}')
|
|
373
264
|
} catch {
|
|
374
265
|
userPreferences = {}
|
|
375
266
|
}
|
|
@@ -388,7 +279,7 @@ settingsApp.get('/notifications', async (context) => {
|
|
|
388
279
|
*/
|
|
389
280
|
settingsApp.put('/notifications', async (context) => {
|
|
390
281
|
const { sub: userId } = context.get('jwtPayload')
|
|
391
|
-
|
|
282
|
+
|
|
392
283
|
let payload: Record<string, unknown>
|
|
393
284
|
try {
|
|
394
285
|
payload = await context.req.json()
|
|
@@ -403,12 +294,8 @@ settingsApp.put('/notifications', async (context) => {
|
|
|
403
294
|
mediaUpload: payload.mediaUpload === true,
|
|
404
295
|
}
|
|
405
296
|
|
|
406
|
-
await context.
|
|
407
|
-
'UPDATE users SET notification_prefs = ? WHERE id = ?'
|
|
408
|
-
).bind(JSON.stringify(newPreferences), userId).run()
|
|
409
|
-
|
|
297
|
+
await context.get('userRepository').updateNotificationPreferences(userId, JSON.stringify(newPreferences))
|
|
410
298
|
return context.json({ success: true })
|
|
411
299
|
})
|
|
412
300
|
|
|
413
301
|
export { settingsApp }
|
|
414
|
-
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/// <reference types="@cloudflare/workers-types" />
|
|
2
2
|
import { Hono } from 'hono'
|
|
3
|
-
import bcrypt from 'bcryptjs'
|
|
4
3
|
import type { Env, Variables } from '../../types'
|
|
5
4
|
import { publicProblem } from '../../public/problem-details'
|
|
6
5
|
|
|
@@ -11,12 +10,8 @@ const setupApp = new Hono<{ Bindings: Env; Variables: Variables }>()
|
|
|
11
10
|
* Checks if the application needs an initial setup (i.e., if no users exist).
|
|
12
11
|
*/
|
|
13
12
|
setupApp.get('/auth/setup', async (context) => {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
.first<{ count: number }>()
|
|
17
|
-
|
|
18
|
-
const needsInitialSetup = (userCountResult?.count ?? 0) === 0
|
|
19
|
-
return context.json({ needsSetup: needsInitialSetup })
|
|
13
|
+
const userCount = await context.get('userRepository').countAll()
|
|
14
|
+
return context.json({ needsSetup: userCount === 0 })
|
|
20
15
|
})
|
|
21
16
|
|
|
22
17
|
/**
|
|
@@ -24,11 +19,9 @@ setupApp.get('/auth/setup', async (context) => {
|
|
|
24
19
|
* Creates the first administrator account. This endpoint is disabled once at least one user exists.
|
|
25
20
|
*/
|
|
26
21
|
setupApp.post('/auth/setup', async (context) => {
|
|
27
|
-
const
|
|
28
|
-
.prepare('SELECT COUNT(*) as count FROM users')
|
|
29
|
-
.first<{ count: number }>()
|
|
22
|
+
const userCount = await context.get('userRepository').countAll()
|
|
30
23
|
|
|
31
|
-
if (
|
|
24
|
+
if (userCount > 0) {
|
|
32
25
|
return publicProblem(context, {
|
|
33
26
|
type: 'setup-already-done',
|
|
34
27
|
title: 'Setup already completed',
|
|
@@ -78,15 +71,17 @@ setupApp.post('/auth/setup', async (context) => {
|
|
|
78
71
|
})
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
const
|
|
82
|
-
const newUserId = crypto.randomUUID()
|
|
74
|
+
const passwordHash = await context.get('hashProvider').hash(password)
|
|
83
75
|
const normalizedEmail = email.trim().toLowerCase()
|
|
84
76
|
const normalizedName = typeof name === 'string' ? name.trim() : null
|
|
85
77
|
|
|
86
|
-
await context.
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
78
|
+
await context.get('userRepository').create({
|
|
79
|
+
id: context.get('idGenerator').uuid(),
|
|
80
|
+
email: normalizedEmail,
|
|
81
|
+
passwordHash,
|
|
82
|
+
role: 'admin',
|
|
83
|
+
name: normalizedName,
|
|
84
|
+
})
|
|
90
85
|
|
|
91
86
|
return context.json({ success: true }, 201)
|
|
92
87
|
})
|