@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.
- package/assets/dashboard/assets/index-BKWnlvnV.css +1 -0
- package/assets/dashboard/assets/index-C1P9BXnU.js +629 -0
- package/assets/dashboard/index.html +2 -2
- package/migrations/0000_v040_base.sql +25 -0
- package/migrations/0029_automations.sql +14 -0
- package/package.json +4 -3
- 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 +80 -80
- package/src/features/automations/__tests__/action-executors.test.ts +268 -0
- package/src/features/automations/__tests__/automation-runner.test.ts +192 -0
- package/src/features/automations/__tests__/automation-runner.utils.test.ts +56 -0
- package/src/features/automations/__tests__/automations.handler.test.ts +260 -0
- package/src/features/automations/__tests__/automations.repository.test.ts +159 -0
- package/src/features/automations/__tests__/automations.schema.test.ts +134 -0
- package/src/features/automations/__tests__/context-resolver.test.ts +122 -0
- package/src/features/automations/__tests__/cron-runner.test.ts +263 -0
- package/src/features/automations/__tests__/cron-runner.utils.test.ts +140 -0
- package/src/features/automations/__tests__/set-variable.executor.test.ts +306 -0
- package/src/features/automations/__tests__/template-grammar.test.ts +304 -0
- package/src/features/automations/__tests__/when-evaluator.test.ts +270 -0
- package/src/features/automations/__tests__/when-pushdown.test.ts +277 -0
- package/src/features/automations/action-executors/create-entry.executor.ts +23 -0
- package/src/features/automations/action-executors/edit-field.executor.ts +22 -0
- package/src/features/automations/action-executors/index.ts +33 -0
- package/src/features/automations/action-executors/send-mail.executor.ts +42 -0
- package/src/features/automations/action-executors/set-variable.executor.ts +146 -0
- package/src/features/automations/action-executors/webhook.executor.ts +25 -0
- package/src/features/automations/automation-runner.ts +81 -0
- package/src/features/automations/automation-runner.utils.ts +43 -0
- package/src/features/automations/automations.handler.ts +193 -0
- package/src/features/automations/automations.schema.ts +160 -0
- package/src/features/automations/context-resolver.ts +148 -0
- package/src/features/automations/cron-runner.ts +136 -0
- package/src/features/automations/cron-runner.utils.ts +40 -0
- package/src/features/automations/filter-translation.ts +42 -0
- package/src/features/automations/index.ts +12 -0
- package/src/features/automations/template-grammar.ts +241 -0
- package/src/features/automations/var-access-resolver.ts +136 -0
- package/src/features/automations/when-evaluator.ts +83 -0
- package/src/features/automations/when-pushdown.ts +53 -0
- package/src/features/content/handlers/create.ts +22 -10
- package/src/features/content/handlers/delete.ts +21 -10
- package/src/features/content/handlers/update.ts +21 -9
- package/src/features/draft/draft.handler.ts +51 -153
- package/src/features/draft/draft.middleware.ts +62 -0
- package/src/features/email/email.service.ts +13 -0
- package/src/features/email/email.types.ts +10 -0
- package/src/features/email/index.ts +2 -1
- package/src/features/email/templates/automation-mail.ts +15 -0
- 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 +64 -176
- package/src/features/setup/index.ts +12 -17
- package/src/features/stats/stats.handler.ts +110 -138
- package/src/index.ts +40 -8
- 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 +72 -5
- package/src/middleware.ts +15 -35
- package/src/public/cache-utils.ts +34 -0
- package/src/public/entry-projection.ts +42 -0
- package/src/public/idempotency.ts +19 -0
- package/src/public/problem-details.ts +5 -0
- package/src/public/public-add.ts +110 -166
- package/src/public/public-edit.ts +4 -3
- package/src/public/public-read.ts +59 -216
- 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/public/read-list.ts +50 -0
- package/src/public/read-single.ts +44 -0
- 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/automations.repository.d1.ts +146 -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.test.ts +76 -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/execution-context-scheduler.ts +9 -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/storage-utils.ts +3 -3
- package/src/shared/system-stats.repository.d1.test.ts +54 -0
- package/src/types.ts +24 -3
- package/src/upload.ts +17 -9
- package/src/widget.ts +112 -253
- package/assets/dashboard/assets/index-CewtCjom.css +0 -1
- 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,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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
const
|
|
54
|
+
const tokenHash = await sha256hex(resetToken as string)
|
|
55
|
+
const nowTimestamp = Math.floor(Date.now() / 1000)
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
62
|
+
const hashedNewPassword = await context.get('hashProvider').hash(newPassword as string)
|
|
88
63
|
|
|
89
|
-
|
|
90
|
-
await
|
|
91
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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(
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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) {
|
|
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
|
|
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)
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
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
|
|
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
|
|
227
|
-
await context.
|
|
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
|
|
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 ?? [])
|
|
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
|
|
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) {
|
|
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
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
})
|