@beechcms/api 0.4.0-preview.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -303
- package/src/features/content/constants.ts +10 -0
- package/src/features/content/handlers/create.ts +153 -0
- package/src/features/content/handlers/delete.ts +76 -0
- package/src/features/content/handlers/facets.ts +45 -0
- package/src/features/content/handlers/get.ts +116 -0
- package/src/features/content/handlers/list.ts +88 -0
- package/src/features/content/handlers/update.ts +207 -0
- package/src/features/content/index.ts +20 -0
- package/src/features/draft/draft.handler.ts +272 -198
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -249
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -11
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -0
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -183
- package/src/public/public-edit.ts +158 -183
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -217
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -241
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -0
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -0
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -0
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -36
- package/src/upload.ts +179 -335
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-CC-jbp6g.js +0 -554
- package/assets/dashboard/assets/index-CQODXprH.css +0 -1
- package/src/content.ts +0 -502
- package/src/features/draft/draft.test.ts +0 -315
- package/src/features/rotate-field/rotate-field.test.ts +0 -297
|
@@ -1,249 +1,414 @@
|
|
|
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
|
|
9
|
-
const MIN_PASSWORD_LENGTH = 8
|
|
10
|
-
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
|
|
38
|
-
key: string
|
|
39
|
-
filename: string
|
|
40
|
-
mime_type: string
|
|
41
|
-
size_bytes: number
|
|
42
|
-
created_at: number
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
9
|
+
const MIN_PASSWORD_LENGTH = 8
|
|
10
|
+
const MAX_PASSWORD_LENGTH = 128
|
|
11
|
+
const BCRYPT_SALT_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 MediaFileRow = {
|
|
38
|
+
key: string
|
|
39
|
+
filename: string
|
|
40
|
+
mime_type: string
|
|
41
|
+
size_bytes: number
|
|
42
|
+
created_at: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* GET /api/settings
|
|
48
|
+
* Retrieves the general site configuration.
|
|
49
|
+
*/
|
|
50
|
+
settingsApp.get('/', async (context) => {
|
|
51
|
+
// General site configuration.
|
|
52
|
+
// In the future, these could be loaded from a 'system_settings' table in D1.
|
|
53
|
+
return context.json({
|
|
54
|
+
siteTitle: 'Beech CMS',
|
|
55
|
+
siteLogo: '/beechLogoDark.svg',
|
|
56
|
+
defaultLanguage: 'it',
|
|
57
|
+
dateFormat: context.env.DATE_FORMAT || 'DD-MM-YYYY',
|
|
58
|
+
features: {
|
|
59
|
+
drafts: true,
|
|
60
|
+
media: true,
|
|
61
|
+
search: true,
|
|
62
|
+
activityLog: true
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* GET /api/settings/me
|
|
69
|
+
* Retrieves the currently authenticated user's profile and preferences.
|
|
70
|
+
*/
|
|
71
|
+
settingsApp.get('/me', async (context) => {
|
|
72
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
73
|
+
|
|
74
|
+
const currentUser = await context.env.DB.prepare(
|
|
75
|
+
'SELECT id, email, name, avatar_url, notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
76
|
+
).bind(userId).first<Omit<UserRow, 'password_hash'>>()
|
|
77
|
+
|
|
78
|
+
if (!currentUser) {
|
|
79
|
+
return context.json({ error: 'User not found' }, 404)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let notificationPreferences: Record<string, boolean>
|
|
83
|
+
try {
|
|
84
|
+
notificationPreferences = JSON.parse(currentUser.notification_prefs || '{}')
|
|
85
|
+
} catch {
|
|
86
|
+
notificationPreferences = {}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return context.json({
|
|
90
|
+
id: currentUser.id,
|
|
91
|
+
email: currentUser.email,
|
|
92
|
+
name: currentUser.name,
|
|
93
|
+
avatarUrl: currentUser.avatar_url,
|
|
94
|
+
notificationPrefs: {
|
|
95
|
+
contentCreate: notificationPreferences.contentCreate ?? true,
|
|
96
|
+
contentUpdate: notificationPreferences.contentUpdate ?? true,
|
|
97
|
+
contentDelete: notificationPreferences.contentDelete ?? true,
|
|
98
|
+
mediaUpload: notificationPreferences.mediaUpload ?? false,
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* PUT /api/settings/profile
|
|
105
|
+
* Updates the user's name and email address.
|
|
106
|
+
*/
|
|
107
|
+
settingsApp.put('/profile', async (context) => {
|
|
108
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
109
|
+
|
|
110
|
+
let payload: Record<string, unknown>
|
|
111
|
+
try {
|
|
112
|
+
payload = await context.req.json()
|
|
113
|
+
} catch {
|
|
114
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const nameInput = typeof payload.name === 'string' ? payload.name.trim() : null
|
|
118
|
+
const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
|
|
119
|
+
|
|
120
|
+
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)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
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)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const fieldsToUpdate: string[] = []
|
|
139
|
+
const valuesToUpdate: unknown[] = []
|
|
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) {
|
|
152
|
+
return context.json({ error: 'No fields to update' }, 400)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check if the new email is already taken by another user
|
|
156
|
+
if (emailInput !== null) {
|
|
157
|
+
const existingUserWithEmail = await context.env.DB.prepare(
|
|
158
|
+
'SELECT id FROM users WHERE email = ? AND id != ? LIMIT 1'
|
|
159
|
+
).bind(emailInput, userId).first()
|
|
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)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
valuesToUpdate.push(userId)
|
|
172
|
+
await context.env.DB.prepare(
|
|
173
|
+
`UPDATE users SET ${fieldsToUpdate.join(', ')} WHERE id = ?`
|
|
174
|
+
).bind(...valuesToUpdate).run()
|
|
175
|
+
|
|
176
|
+
return context.json({ success: true })
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* PUT /api/settings/password
|
|
181
|
+
* Updates the user's password after verifying the current one.
|
|
182
|
+
*/
|
|
183
|
+
settingsApp.put('/password', async (context) => {
|
|
184
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
185
|
+
|
|
186
|
+
let payload: Record<string, unknown>
|
|
187
|
+
try {
|
|
188
|
+
payload = await context.req.json()
|
|
189
|
+
} catch {
|
|
190
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : ''
|
|
194
|
+
const newPassword = typeof payload.newPassword === 'string' ? payload.newPassword : ''
|
|
195
|
+
|
|
196
|
+
if (!currentPassword || !newPassword) {
|
|
197
|
+
return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (newPassword.length < MIN_PASSWORD_LENGTH) {
|
|
201
|
+
return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (newPassword.length > MAX_PASSWORD_LENGTH) {
|
|
205
|
+
return context.json({ error: 'Password is too long' }, 400)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const userRecord = await context.env.DB.prepare(
|
|
209
|
+
'SELECT password_hash FROM users WHERE id = ? LIMIT 1'
|
|
210
|
+
).bind(userId).first<{ password_hash: string }>()
|
|
211
|
+
|
|
212
|
+
if (!userRecord) {
|
|
213
|
+
return context.json({ error: 'User not found' }, 404)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const isPasswordCorrect = await bcrypt.compare(currentPassword, userRecord.password_hash)
|
|
217
|
+
if (!isPasswordCorrect) {
|
|
218
|
+
return context.json({
|
|
219
|
+
type: 'invalid-credentials',
|
|
220
|
+
title: 'Unauthorized',
|
|
221
|
+
status: 401,
|
|
222
|
+
detail: 'Current password is incorrect'
|
|
223
|
+
}, 401)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const hashedNewPassword = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS)
|
|
227
|
+
await context.env.DB.prepare(
|
|
228
|
+
'UPDATE users SET password_hash = ? WHERE id = ?'
|
|
229
|
+
).bind(hashedNewPassword, userId).run()
|
|
230
|
+
|
|
231
|
+
return context.json({ success: true })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* PUT /api/settings/avatar
|
|
236
|
+
* Updates the user's avatar URL.
|
|
237
|
+
*/
|
|
238
|
+
settingsApp.put('/avatar', async (context) => {
|
|
239
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
240
|
+
|
|
241
|
+
let payload: Record<string, unknown>
|
|
242
|
+
try {
|
|
243
|
+
payload = await context.req.json()
|
|
244
|
+
} catch {
|
|
245
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
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
|
+
|
|
254
|
+
return context.json({ success: true })
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* GET /api/settings/sessions
|
|
259
|
+
* Retrieves a list of active refresh tokens for the user.
|
|
260
|
+
*/
|
|
261
|
+
settingsApp.get('/sessions', async (context) => {
|
|
262
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
263
|
+
const currentTimestamp = Math.floor(Date.now() / 1000)
|
|
264
|
+
|
|
265
|
+
const sessionsResult = await context.env.DB.prepare(
|
|
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 ?? [])
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* DELETE /api/settings/sessions/:id
|
|
276
|
+
* Revokes a specific refresh token (session).
|
|
277
|
+
*/
|
|
278
|
+
settingsApp.delete('/sessions/:id', async (context) => {
|
|
279
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
280
|
+
const sessionId = context.req.param('id')
|
|
281
|
+
const currentTimestamp = Math.floor(Date.now() / 1000)
|
|
282
|
+
|
|
283
|
+
const dbUpdateResult = await context.env.DB.prepare(
|
|
284
|
+
`UPDATE refresh_tokens SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL`
|
|
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) {
|
|
290
|
+
return context.json({ error: 'Session not found or already revoked' }, 404)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return context.json({ success: true })
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* GET /api/settings/activity
|
|
298
|
+
* Retrieves the latest activity logs for the user.
|
|
299
|
+
*/
|
|
300
|
+
settingsApp.get('/activity', async (context) => {
|
|
301
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
302
|
+
|
|
303
|
+
const activityLogsResult = await context.env.DB.prepare(
|
|
304
|
+
`SELECT id, action, entity_type, entity_slug, details, created_at
|
|
305
|
+
FROM activity_logs WHERE user_id = ?
|
|
306
|
+
ORDER BY created_at DESC LIMIT 30`
|
|
307
|
+
).bind(userId).all<ActivityRow>()
|
|
308
|
+
|
|
309
|
+
return context.json(activityLogsResult.results ?? [])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* GET /api/settings/storage
|
|
314
|
+
* Calculates storage usage and identifies orphaned media files.
|
|
315
|
+
*/
|
|
316
|
+
settingsApp.get('/storage', async (context) => {
|
|
317
|
+
const mediaRepo = context.get('mediaRepository')
|
|
318
|
+
const statsRepo = context.get('systemStatsRepository')
|
|
319
|
+
|
|
320
|
+
const totalStorageUsedBytes = await statsRepo.getStorageUsage()
|
|
321
|
+
const totalFileCount = await mediaRepo.count()
|
|
322
|
+
|
|
323
|
+
// Collect all media keys referenced in file-type columns across all seeds
|
|
324
|
+
const referencedMediaKeys = new Set<string>()
|
|
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
|
+
}
|
|
344
|
+
|
|
345
|
+
const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
|
|
346
|
+
const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
|
|
347
|
+
|
|
348
|
+
return context.json({
|
|
349
|
+
totalBytes: totalStorageUsedBytes,
|
|
350
|
+
fileCount: totalFileCount,
|
|
351
|
+
orphans: orphanedMediaFiles,
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* GET /api/settings/notifications
|
|
357
|
+
* Retrieves the user's notification preferences.
|
|
358
|
+
*/
|
|
359
|
+
settingsApp.get('/notifications', async (context) => {
|
|
360
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
361
|
+
|
|
362
|
+
const userRecord = await context.env.DB.prepare(
|
|
363
|
+
'SELECT notification_prefs FROM users WHERE id = ? LIMIT 1'
|
|
364
|
+
).bind(userId).first<{ notification_prefs: string }>()
|
|
365
|
+
|
|
366
|
+
if (!userRecord) {
|
|
367
|
+
return context.json({ error: 'User not found' }, 404)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let userPreferences: Record<string, boolean>
|
|
371
|
+
try {
|
|
372
|
+
userPreferences = JSON.parse(userRecord.notification_prefs || '{}')
|
|
373
|
+
} catch {
|
|
374
|
+
userPreferences = {}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return context.json({
|
|
378
|
+
contentCreate: userPreferences.contentCreate ?? true,
|
|
379
|
+
contentUpdate: userPreferences.contentUpdate ?? true,
|
|
380
|
+
contentDelete: userPreferences.contentDelete ?? true,
|
|
381
|
+
mediaUpload: userPreferences.mediaUpload ?? false,
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* PUT /api/settings/notifications
|
|
387
|
+
* Updates the user's notification preferences.
|
|
388
|
+
*/
|
|
389
|
+
settingsApp.put('/notifications', async (context) => {
|
|
390
|
+
const { sub: userId } = context.get('jwtPayload')
|
|
391
|
+
|
|
392
|
+
let payload: Record<string, unknown>
|
|
393
|
+
try {
|
|
394
|
+
payload = await context.req.json()
|
|
395
|
+
} catch {
|
|
396
|
+
return context.json({ error: 'Invalid JSON body' }, 400)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const newPreferences = {
|
|
400
|
+
contentCreate: payload.contentCreate === true,
|
|
401
|
+
contentUpdate: payload.contentUpdate === true,
|
|
402
|
+
contentDelete: payload.contentDelete === true,
|
|
403
|
+
mediaUpload: payload.mediaUpload === true,
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
await context.env.DB.prepare(
|
|
407
|
+
'UPDATE users SET notification_prefs = ? WHERE id = ?'
|
|
408
|
+
).bind(JSON.stringify(newPreferences), userId).run()
|
|
409
|
+
|
|
410
|
+
return context.json({ success: true })
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
export { settingsApp }
|
|
414
|
+
|