@beechcms/api 0.4.0 → 0.4.2

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.
Files changed (138) hide show
  1. package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
  2. package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
  3. package/assets/dashboard/index.html +2 -2
  4. package/migrations/0000_v040_base.sql +25 -0
  5. package/migrations/0029_automations.sql +14 -0
  6. package/package.json +4 -3
  7. package/src/auth/bcrypt-hash-provider.ts +20 -0
  8. package/src/auth/constants.ts +3 -3
  9. package/src/auth/generate-refresh-token.test.ts +19 -0
  10. package/src/auth/hash-provider.test.ts +46 -0
  11. package/src/auth/in-memory-hash-provider.ts +13 -0
  12. package/src/auth/jose-token-service.ts +55 -0
  13. package/src/auth/login.test.ts +92 -0
  14. package/src/auth/login.ts +15 -32
  15. package/src/auth/refresh.ts +0 -122
  16. package/src/auth/static-token-service.ts +18 -0
  17. package/src/auth/token-service.test.ts +82 -0
  18. package/src/factory.ts +80 -80
  19. package/src/features/automations/__tests__/action-executors.test.ts +268 -0
  20. package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
  21. package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
  22. package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
  23. package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
  24. package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
  25. package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
  26. package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
  27. package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
  28. package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
  29. package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
  30. package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
  31. package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
  32. package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
  33. package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
  34. package/src/features/automations/action-executors/index.ts +33 -0
  35. package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
  36. package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
  37. package/src/features/automations/action-executors/webhook.executor.ts +25 -0
  38. package/src/features/automations/automation-runner.ts +81 -0
  39. package/src/features/automations/automation-runner.utils.ts +43 -0
  40. package/src/features/automations/automations.handler.ts +193 -0
  41. package/src/features/automations/automations.schema.ts +160 -0
  42. package/src/features/automations/context-resolver.ts +148 -0
  43. package/src/features/automations/cron-runner.ts +136 -0
  44. package/src/features/automations/cron-runner.utils.ts +40 -0
  45. package/src/features/automations/filter-translation.ts +42 -0
  46. package/src/features/automations/index.ts +12 -0
  47. package/src/features/automations/template-grammar.ts +241 -0
  48. package/src/features/automations/var-access-resolver.ts +136 -0
  49. package/src/features/automations/when-evaluator.ts +83 -0
  50. package/src/features/automations/when-pushdown.ts +53 -0
  51. package/src/features/content/handlers/create.ts +22 -10
  52. package/src/features/content/handlers/delete.ts +21 -10
  53. package/src/features/content/handlers/update.ts +21 -9
  54. package/src/features/draft/draft.handler.ts +51 -153
  55. package/src/features/draft/draft.middleware.ts +62 -0
  56. package/src/features/email/email.service.ts +13 -0
  57. package/src/features/email/email.types.ts +10 -0
  58. package/src/features/email/index.ts +2 -1
  59. package/src/features/email/templates/automation-mail.ts +15 -0
  60. package/src/features/notifications/notifications.handler.ts +25 -54
  61. package/src/features/password-reset/request.ts +17 -41
  62. package/src/features/password-reset/reset.ts +18 -54
  63. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  64. package/src/features/schema/schema.handler.ts +1 -1
  65. package/src/features/settings/settings.handler.ts +64 -176
  66. package/src/features/setup/index.ts +12 -17
  67. package/src/features/stats/stats.handler.ts +110 -138
  68. package/src/index.ts +40 -8
  69. package/src/middleware/auth-providers.middleware.ts +32 -0
  70. package/src/middleware/observability.middleware.ts +52 -0
  71. package/src/middleware/rate-limit.middleware.ts +41 -0
  72. package/src/middleware/repository.middleware.ts +72 -5
  73. package/src/middleware.ts +15 -35
  74. package/src/public/cache-utils.ts +34 -0
  75. package/src/public/entry-projection.ts +42 -0
  76. package/src/public/idempotency.ts +19 -0
  77. package/src/public/problem-details.ts +5 -0
  78. package/src/public/public-add.ts +110 -166
  79. package/src/public/public-edit.ts +4 -3
  80. package/src/public/public-read.ts +59 -216
  81. package/src/public/public-routes.ts +2 -2
  82. package/src/public/query-builder.test.ts +220 -0
  83. package/src/public/rate-limit-middleware.ts +7 -19
  84. package/src/public/read-list.ts +50 -0
  85. package/src/public/read-single.ts +44 -0
  86. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  87. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  88. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  89. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  90. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  91. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  92. package/src/search-utils.test.ts +207 -0
  93. package/src/search-utils.ts +18 -1
  94. package/src/search.ts +24 -35
  95. package/src/shared/apply-policies.test.ts +77 -0
  96. package/src/shared/automations.repository.d1.ts +146 -0
  97. package/src/shared/background-notification-service.test.ts +58 -0
  98. package/src/shared/background-notification-service.ts +48 -0
  99. package/src/shared/content-utils.test.ts +161 -0
  100. package/src/shared/content.repository.d1.test.ts +312 -0
  101. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  102. package/src/shared/d1-activity-log.repository.ts +101 -0
  103. package/src/shared/d1-activity-logger.test.ts +82 -0
  104. package/src/shared/d1-activity-logger.ts +63 -0
  105. package/src/shared/d1-analytics.repository.test.ts +74 -0
  106. package/src/shared/d1-analytics.repository.ts +81 -0
  107. package/src/shared/d1-content-scan.repository.test.ts +76 -0
  108. package/src/shared/d1-content-scan.repository.ts +29 -0
  109. package/src/shared/d1-notification.repository.test.ts +124 -0
  110. package/src/shared/d1-notification.repository.ts +114 -0
  111. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  112. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  113. package/src/shared/d1-search.repository.test.ts +83 -0
  114. package/src/shared/d1-search.repository.ts +84 -0
  115. package/src/shared/d1-session.repository.test.ts +121 -0
  116. package/src/shared/d1-session.repository.ts +98 -0
  117. package/src/shared/d1-user.repository.test.ts +147 -0
  118. package/src/shared/d1-user.repository.ts +109 -0
  119. package/src/shared/d1-widget.repository.test.ts +217 -0
  120. package/src/shared/d1-widget.repository.ts +337 -0
  121. package/src/shared/execution-context-scheduler.ts +9 -0
  122. package/src/shared/fixed-clock.ts +21 -0
  123. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  124. package/src/shared/in-memory-activity-logger.ts +15 -0
  125. package/src/shared/in-memory-notification-service.ts +15 -0
  126. package/src/shared/media.repository.d1.test.ts +103 -0
  127. package/src/shared/media.repository.d1.ts +1 -1
  128. package/src/shared/request-utils.ts +22 -0
  129. package/src/shared/sequential-id-generator.ts +22 -0
  130. package/src/shared/storage-utils.ts +3 -3
  131. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  132. package/src/types.ts +24 -3
  133. package/src/upload.ts +17 -9
  134. package/src/widget.ts +112 -253
  135. package/assets/dashboard/assets/index-CewtCjom.css +0 -1
  136. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  137. package/src/shared/activity-logger.ts +0 -79
  138. package/src/shared/notification-service.ts +0 -56
@@ -1,25 +1,13 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import type { Context } from 'hono'
3
- import bcrypt from 'bcryptjs'
4
3
  import type { Env, Variables } from '../../types'
5
4
  import { sendPasswordChangedEmail, resolveEmailLocale } from '../email'
5
+ import { sha256hex } from '@beechcms/core'
6
+ import { getClientIp } from '../../shared/request-utils'
6
7
 
7
8
  const MIN_PASSWORD_LENGTH = 8
8
9
  const MAX_PASSWORD_LENGTH = 128
9
10
 
10
- /**
11
- * Computes the SHA-256 hash of a string and returns it as a hex string.
12
- */
13
- async function computeSha256Hash(text: string): Promise<string> {
14
- const encoder = new TextEncoder()
15
- const data = encoder.encode(text)
16
- const hashBuffer = await crypto.subtle.digest('SHA-256', data)
17
-
18
- return Array.from(new Uint8Array(hashBuffer))
19
- .map(byte => byte.toString(16).padStart(2, '0'))
20
- .join('')
21
- }
22
-
23
11
  /**
24
12
  * Handles the actual password reset process using a valid token.
25
13
  */
@@ -32,14 +20,10 @@ export async function resetPassword(
32
20
  return context.json({ error: 'Service not available' }, 503)
33
21
  }
34
22
 
35
- // Rate limiting: e.g., 5 attempts per IP per 60 seconds
36
- if (env.RESET_PASSWORD_RATE_LIMITER) {
37
- const clientIpAddress = req.raw.headers.get('cf-connecting-ip') ?? 'unknown'
38
- const { success: isRateLimitAllowed } = await env.RESET_PASSWORD_RATE_LIMITER.limit({ key: clientIpAddress })
39
-
40
- if (!isRateLimitAllowed) {
41
- return context.json({ error: 'Too many requests' }, 429)
42
- }
23
+ const clientIpAddress = getClientIp(req)
24
+ const resetPasswordRateLimit = await context.get('rateLimiters').getLimiter('resetPassword').checkLimit(clientIpAddress)
25
+ if (!resetPasswordRateLimit.isAllowed) {
26
+ return context.json({ error: 'Too many requests' }, 429)
43
27
  }
44
28
 
45
29
  let payload: Record<string, unknown>
@@ -55,55 +39,36 @@ export async function resetPassword(
55
39
  }
56
40
 
57
41
  const newPassword = payload.password
58
- if (
42
+ const isPasswordInvalid =
59
43
  typeof newPassword !== 'string' ||
60
44
  newPassword.length < MIN_PASSWORD_LENGTH ||
61
45
  newPassword.length > MAX_PASSWORD_LENGTH
62
- ) {
46
+
47
+ if (isPasswordInvalid) {
63
48
  return context.json({
64
49
  error: `Password must be between ${MIN_PASSWORD_LENGTH} and ${MAX_PASSWORD_LENGTH} characters`,
65
50
  }, 400)
66
51
  }
67
52
 
68
53
  const emailLocale = resolveEmailLocale(payload.locale)
69
- const hashedResetToken = await computeSha256Hash(resetToken)
70
- const currentTimestamp = Math.floor(Date.now() / 1000)
54
+ const tokenHash = await sha256hex(resetToken as string)
55
+ const nowTimestamp = Math.floor(Date.now() / 1000)
71
56
 
72
- // JOIN with users table to retrieve the email in a single query - needed for notification.
73
- const resetTokenRecord = await env.DB
74
- .prepare(
75
- `SELECT prt.id, prt.user_id, u.email
76
- FROM password_reset_tokens prt
77
- JOIN users u ON u.id = prt.user_id
78
- WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`,
79
- )
80
- .bind(hashedResetToken, currentTimestamp)
81
- .first<{ id: string; user_id: string; email: string }>()
82
-
83
- if (!resetTokenRecord) {
57
+ const tokenRecord = await context.get('passwordResetTokenRepository').findValidByHashWithEmail(tokenHash, nowTimestamp)
58
+ if (!tokenRecord) {
84
59
  return context.json({ error: 'Invalid or expired token' }, 400)
85
60
  }
86
61
 
87
- const hashedNewPassword = await bcrypt.hash(newPassword, 10)
62
+ const hashedNewPassword = await context.get('hashProvider').hash(newPassword as string)
88
63
 
89
- // Mark token as used, update user password, and revoke all active sessions - atomically.
90
- await env.DB.batch([
91
- env.DB
92
- .prepare('UPDATE password_reset_tokens SET used_at = unixepoch() WHERE id = ?')
93
- .bind(resetTokenRecord.id),
94
- env.DB
95
- .prepare('UPDATE users SET password_hash = ? WHERE id = ?')
96
- .bind(hashedNewPassword, resetTokenRecord.user_id),
97
- env.DB
98
- .prepare('UPDATE refresh_tokens SET revoked_at = unixepoch() WHERE user_id = ? AND revoked_at IS NULL')
99
- .bind(resetTokenRecord.user_id),
100
- ])
64
+ await context.get('passwordResetTokenRepository').markUsed(tokenRecord.id, nowTimestamp)
65
+ await context.get('userRepository').updatePasswordHash(tokenRecord.userId, hashedNewPassword)
66
+ await context.get('sessionRepository').revokeAllForUser(tokenRecord.userId, nowTimestamp)
101
67
 
102
- // Notify user that password was changed - fire-and-forget via waitUntil, doesn't block response.
103
68
  const sendNotification = async () => {
104
69
  try {
105
70
  await sendPasswordChangedEmail({
106
- to: resetTokenRecord.email,
71
+ to: tokenRecord.email,
107
72
  locale: emailLocale,
108
73
  apiKey: env.RESEND_API_KEY!,
109
74
  from: env.EMAIL_FROM,
@@ -125,4 +90,3 @@ export async function resetPassword(
125
90
 
126
91
  return context.json({ success: true })
127
92
  }
128
-
@@ -1,6 +1,6 @@
1
1
  /// <reference types="@cloudflare/workers-types" />
2
2
  import { Hono } from 'hono'
3
- import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, serializeForDb } from '@beechcms/core'
3
+ import { resolvePolicies, verifyHashField, sha256hex, validateAndSanitizeSeedPayload, EntryNotFoundError } from '@beechcms/core'
4
4
  import { publicProblem } from '../../public/problem-details'
5
5
  import { rotateFieldRequestSchema } from './rotate-field.schema'
6
6
  import type { Env, Variables } from '../../types'
@@ -65,20 +65,19 @@ rotateFieldApp.post('/:slug/:id/rotate-field', async (context) => {
65
65
  })
66
66
  }
67
67
 
68
- const database = context.env.DB
69
-
70
- // In v0.4.0, the hash value is stored in a dedicated column: contentRecord[targetFieldBranch.alias]
71
- const contentRecord = await database.prepare(`SELECT ${targetFieldBranch.alias} FROM content_${seedSlug} WHERE id = ? LIMIT 1`)
72
- .bind(entryId)
73
- .first<Record<string, unknown>>()
74
-
75
- if (!contentRecord) {
76
- return publicProblem(context, {
77
- type: 'content-not-found',
78
- title: 'Not Found',
79
- status: 404,
80
- detail: `Entry '${entryId}' not found`
81
- })
68
+ let contentRecord: Record<string, unknown>
69
+ try {
70
+ contentRecord = await context.get('repository').findById(seed, entryId)
71
+ } catch (error) {
72
+ if (error instanceof EntryNotFoundError) {
73
+ return publicProblem(context, {
74
+ type: 'content-not-found',
75
+ title: 'Not Found',
76
+ status: 404,
77
+ detail: `Entry '${entryId}' not found`
78
+ })
79
+ }
80
+ throw error
82
81
  }
83
82
 
84
83
  const storedFieldValueHash = contentRecord[targetFieldBranch.alias]
@@ -118,11 +117,8 @@ rotateFieldApp.post('/:slug/:id/rotate-field', async (context) => {
118
117
  }
119
118
 
120
119
  const newFieldValueHash = await sha256hex(nextValue)
121
- const currentTimestamp = Math.floor(Date.now() / 1000)
122
120
 
123
- await database.prepare(`UPDATE content_${seedSlug} SET ${targetFieldBranch.alias} = ?, updated_at = ? WHERE id = ?`)
124
- .bind(serializeForDb(targetFieldBranch, newFieldValueHash), currentTimestamp, entryId)
125
- .run()
121
+ await context.get('repository').update(seed, entryId, { [targetFieldBranch.alias]: newFieldValueHash })
126
122
 
127
123
  return context.json({ success: true })
128
124
  })
@@ -10,7 +10,7 @@ const schemaApp = new Hono<{ Bindings: Env; Variables: Variables }>()
10
10
  */
11
11
  schemaApp.get('/', async (context) => {
12
12
  const registry = context.get('seedRegistry')
13
- return context.json(Object.values(registry))
13
+ return context.json(registry.all())
14
14
  })
15
15
 
16
16
  export { schemaApp }
@@ -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 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
-
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',
@@ -59,7 +24,8 @@ settingsApp.get('/', async (context) => {
59
24
  drafts: true,
60
25
  media: true,
61
26
  search: true,
62
- activityLog: true
27
+ activityLog: true,
28
+ email: !!(context.env.EMAIL_API_KEY || context.env.RESEND_API_KEY),
63
29
  }
64
30
  })
65
31
  })
@@ -70,18 +36,15 @@ settingsApp.get('/', async (context) => {
70
36
  */
71
37
  settingsApp.get('/me', async (context) => {
72
38
  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
-
39
+
40
+ const currentUser = await context.get('userRepository').findById(userId)
78
41
  if (!currentUser) {
79
42
  return context.json({ error: 'User not found' }, 404)
80
43
  }
81
44
 
82
45
  let notificationPreferences: Record<string, boolean>
83
46
  try {
84
- notificationPreferences = JSON.parse(currentUser.notification_prefs || '{}')
47
+ notificationPreferences = JSON.parse(currentUser.notificationPreferences || '{}')
85
48
  } catch {
86
49
  notificationPreferences = {}
87
50
  }
@@ -90,7 +53,7 @@ settingsApp.get('/me', async (context) => {
90
53
  id: currentUser.id,
91
54
  email: currentUser.email,
92
55
  name: currentUser.name,
93
- avatarUrl: currentUser.avatar_url,
56
+ avatarUrl: currentUser.avatarUrl,
94
57
  notificationPrefs: {
95
58
  contentCreate: notificationPreferences.contentCreate ?? true,
96
59
  contentUpdate: notificationPreferences.contentUpdate ?? true,
@@ -106,7 +69,7 @@ settingsApp.get('/me', async (context) => {
106
69
  */
107
70
  settingsApp.put('/profile', async (context) => {
108
71
  const { sub: userId } = context.get('jwtPayload')
109
-
72
+
110
73
  let payload: Record<string, unknown>
111
74
  try {
112
75
  payload = await context.req.json()
@@ -118,61 +81,30 @@ settingsApp.put('/profile', async (context) => {
118
81
  const emailInput = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : null
119
82
 
120
83
  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)
84
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Invalid email format' }, 400)
127
85
  }
128
-
86
+
129
87
  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)
88
+ return context.json({ type: 'bad-request', title: 'Bad Request', status: 400, detail: 'Name is too long (maximum 100 characters)' }, 400)
136
89
  }
137
90
 
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) {
91
+ const hasNoFields = nameInput === null && emailInput === null
92
+ if (hasNoFields) {
152
93
  return context.json({ error: 'No fields to update' }, 400)
153
94
  }
154
95
 
155
- // Check if the new email is already taken by another user
156
96
  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)
97
+ const emailTaken = await context.get('userRepository').emailBelongsToAnotherUser(emailInput, userId)
98
+ if (emailTaken) {
99
+ return context.json({ type: 'conflict', title: 'Conflict', status: 409, detail: 'Email address is already in use' }, 409)
168
100
  }
169
101
  }
170
102
 
171
- valuesToUpdate.push(userId)
172
- await context.env.DB.prepare(
173
- `UPDATE users SET ${fieldsToUpdate.join(', ')} WHERE id = ?`
174
- ).bind(...valuesToUpdate).run()
175
-
103
+ const fieldsToUpdate: { name?: string; email?: string } = {}
104
+ if (nameInput !== null) fieldsToUpdate.name = nameInput
105
+ if (emailInput !== null) fieldsToUpdate.email = emailInput
106
+
107
+ await context.get('userRepository').updateProfile(userId, fieldsToUpdate)
176
108
  return context.json({ success: true })
177
109
  })
178
110
 
@@ -182,7 +114,7 @@ settingsApp.put('/profile', async (context) => {
182
114
  */
183
115
  settingsApp.put('/password', async (context) => {
184
116
  const { sub: userId } = context.get('jwtPayload')
185
-
117
+
186
118
  let payload: Record<string, unknown>
187
119
  try {
188
120
  payload = await context.req.json()
@@ -196,38 +128,26 @@ settingsApp.put('/password', async (context) => {
196
128
  if (!currentPassword || !newPassword) {
197
129
  return context.json({ error: 'Both currentPassword and newPassword are required' }, 400)
198
130
  }
199
-
200
131
  if (newPassword.length < MIN_PASSWORD_LENGTH) {
201
132
  return context.json({ error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters long` }, 400)
202
133
  }
203
-
204
134
  if (newPassword.length > MAX_PASSWORD_LENGTH) {
205
135
  return context.json({ error: 'Password is too long' }, 400)
206
136
  }
207
137
 
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
-
138
+ const userRecord = await context.get('userRepository').findById(userId)
212
139
  if (!userRecord) {
213
140
  return context.json({ error: 'User not found' }, 404)
214
141
  }
215
142
 
216
- const isPasswordCorrect = await bcrypt.compare(currentPassword, userRecord.password_hash)
143
+ const hashProvider = context.get('hashProvider')
144
+ const isPasswordCorrect = await hashProvider.verify(currentPassword, userRecord.passwordHash)
217
145
  if (!isPasswordCorrect) {
218
- return context.json({
219
- type: 'invalid-credentials',
220
- title: 'Unauthorized',
221
- status: 401,
222
- detail: 'Current password is incorrect'
223
- }, 401)
146
+ return context.json({ type: 'invalid-credentials', title: 'Unauthorized', status: 401, detail: 'Current password is incorrect' }, 401)
224
147
  }
225
148
 
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
-
149
+ const hashedNewPassword = await hashProvider.hash(newPassword)
150
+ await context.get('userRepository').updatePasswordHash(userId, hashedNewPassword)
231
151
  return context.json({ success: true })
232
152
  })
233
153
 
@@ -237,7 +157,7 @@ settingsApp.put('/password', async (context) => {
237
157
  */
238
158
  settingsApp.put('/avatar', async (context) => {
239
159
  const { sub: userId } = context.get('jwtPayload')
240
-
160
+
241
161
  let payload: Record<string, unknown>
242
162
  try {
243
163
  payload = await context.req.json()
@@ -246,11 +166,7 @@ settingsApp.put('/avatar', async (context) => {
246
166
  }
247
167
 
248
168
  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
-
169
+ await context.get('userRepository').updateAvatarUrl(userId, avatarUrl)
254
170
  return context.json({ success: true })
255
171
  })
256
172
 
@@ -260,15 +176,9 @@ settingsApp.put('/avatar', async (context) => {
260
176
  */
261
177
  settingsApp.get('/sessions', async (context) => {
262
178
  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 ?? [])
179
+ const nowTimestamp = Math.floor(Date.now() / 1000)
180
+ const sessions = await context.get('sessionRepository').listActiveForUser(userId, nowTimestamp, SESSION_LIST_LIMIT)
181
+ return context.json(sessions)
272
182
  })
273
183
 
274
184
  /**
@@ -278,18 +188,13 @@ settingsApp.get('/sessions', async (context) => {
278
188
  settingsApp.delete('/sessions/:id', async (context) => {
279
189
  const { sub: userId } = context.get('jwtPayload')
280
190
  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) {
191
+ const nowTimestamp = Math.floor(Date.now() / 1000)
192
+
193
+ const wasRevoked = await context.get('sessionRepository').revokeById(sessionId, userId, nowTimestamp)
194
+ if (!wasRevoked) {
290
195
  return context.json({ error: 'Session not found or already revoked' }, 404)
291
196
  }
292
-
197
+
293
198
  return context.json({ success: true })
294
199
  })
295
200
 
@@ -299,14 +204,23 @@ settingsApp.delete('/sessions/:id', async (context) => {
299
204
  */
300
205
  settingsApp.get('/activity', async (context) => {
301
206
  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 ?? [])
207
+
208
+ const entries = await context.get('activityLogRepository').list({
209
+ userId,
210
+ limit: ACTIVITY_LOG_LIMIT,
211
+ })
212
+
213
+ // Preserve legacy snake_case shape consumed by the dashboard activity tab.
214
+ const responseEntries = entries.map((entry) => ({
215
+ id: entry.id,
216
+ action: entry.action,
217
+ entity_type: entry.entityType,
218
+ entity_slug: entry.entitySlug,
219
+ details: entry.details ? JSON.stringify(entry.details) : null,
220
+ created_at: entry.createdAt,
221
+ }))
222
+
223
+ return context.json(responseEntries)
310
224
  })
311
225
 
312
226
  /**
@@ -316,31 +230,12 @@ settingsApp.get('/activity', async (context) => {
316
230
  settingsApp.get('/storage', async (context) => {
317
231
  const mediaRepo = context.get('mediaRepository')
318
232
  const statsRepo = context.get('systemStatsRepository')
319
-
233
+
320
234
  const totalStorageUsedBytes = await statsRepo.getStorageUsage()
321
235
  const totalFileCount = await mediaRepo.count()
322
236
 
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
- }
237
+ const registeredSeeds = context.get('seedRegistry').all()
238
+ const referencedMediaKeys = await context.get('contentScanRepository').getReferencedMediaKeys(registeredSeeds)
344
239
 
345
240
  const { items: allMediaRows } = await mediaRepo.list({ limit: 50, offset: 0 })
346
241
  const orphanedMediaFiles = allMediaRows.filter(mediaFile => !referencedMediaKeys.has(mediaFile.key))
@@ -358,18 +253,15 @@ settingsApp.get('/storage', async (context) => {
358
253
  */
359
254
  settingsApp.get('/notifications', async (context) => {
360
255
  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
-
256
+
257
+ const userRecord = await context.get('userRepository').findById(userId)
366
258
  if (!userRecord) {
367
259
  return context.json({ error: 'User not found' }, 404)
368
260
  }
369
261
 
370
262
  let userPreferences: Record<string, boolean>
371
263
  try {
372
- userPreferences = JSON.parse(userRecord.notification_prefs || '{}')
264
+ userPreferences = JSON.parse(userRecord.notificationPreferences || '{}')
373
265
  } catch {
374
266
  userPreferences = {}
375
267
  }
@@ -388,7 +280,7 @@ settingsApp.get('/notifications', async (context) => {
388
280
  */
389
281
  settingsApp.put('/notifications', async (context) => {
390
282
  const { sub: userId } = context.get('jwtPayload')
391
-
283
+
392
284
  let payload: Record<string, unknown>
393
285
  try {
394
286
  payload = await context.req.json()
@@ -403,12 +295,8 @@ settingsApp.put('/notifications', async (context) => {
403
295
  mediaUpload: payload.mediaUpload === true,
404
296
  }
405
297
 
406
- await context.env.DB.prepare(
407
- 'UPDATE users SET notification_prefs = ? WHERE id = ?'
408
- ).bind(JSON.stringify(newPreferences), userId).run()
409
-
298
+ await context.get('userRepository').updateNotificationPreferences(userId, JSON.stringify(newPreferences))
410
299
  return context.json({ success: true })
411
300
  })
412
301
 
413
302
  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 userCountResult = await context.env.DB
15
- .prepare('SELECT COUNT(*) as count FROM users')
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 userCountResult = await context.env.DB
28
- .prepare('SELECT COUNT(*) as count FROM users')
29
- .first<{ count: number }>()
22
+ const userCount = await context.get('userRepository').countAll()
30
23
 
31
- if ((userCountResult?.count ?? 0) > 0) {
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 hashedPassword = await bcrypt.hash(password, 12)
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.env.DB
87
- .prepare('INSERT INTO users (id, email, password_hash, role, name) VALUES (?, ?, ?, ?, ?)')
88
- .bind(newUserId, normalizedEmail, hashedPassword, 'admin', normalizedName)
89
- .run()
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
  })