@consilioweb/payload-support 3.0.0 → 4.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 +36 -2
- package/dist/index.cjs +558 -94
- package/dist/index.d.cts +20 -5
- package/dist/index.d.ts +20 -5
- package/dist/index.js +558 -94
- package/package.json +1 -1
- 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 +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- 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/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 +11 -3
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- package/src/utils/webhookDispatcher.ts +5 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { initTransaction, commitTransaction, killTransaction, getFieldsToSign, jwtSign, APIError } from 'payload';
|
|
2
2
|
import crypto3, { createHash, timingSafeEqual, createHmac, randomBytes } from 'crypto';
|
|
3
3
|
import PDFDocument from 'pdfkit';
|
|
4
|
+
import { lookup } from 'dns/promises';
|
|
4
5
|
import webpush from 'web-push';
|
|
5
6
|
import sanitizeHtml from 'sanitize-html';
|
|
6
7
|
|
|
@@ -158,22 +159,44 @@ var PayloadRateLimitStore = class {
|
|
|
158
159
|
return { payload: context };
|
|
159
160
|
}
|
|
160
161
|
};
|
|
162
|
+
function principalRateKey(user) {
|
|
163
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
164
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
165
|
+
return `${collection}:${String(user.id)}`;
|
|
166
|
+
}
|
|
161
167
|
var RateLimiter = class {
|
|
162
|
-
|
|
168
|
+
/**
|
|
169
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
170
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
171
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
172
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
173
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
174
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
175
|
+
* `RateLimiter` is part of the published API surface.
|
|
176
|
+
*/
|
|
177
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
163
178
|
this.windowMs = windowMs;
|
|
164
179
|
this.maxRequests = maxRequests;
|
|
165
180
|
this.store = store ?? new MemoryRateLimitStore();
|
|
181
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
166
182
|
}
|
|
167
183
|
windowMs;
|
|
168
184
|
maxRequests;
|
|
169
185
|
store;
|
|
186
|
+
prefix;
|
|
187
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
188
|
+
scopedKey(key) {
|
|
189
|
+
return `${this.prefix}${key}`;
|
|
190
|
+
}
|
|
170
191
|
async check(key, context) {
|
|
171
|
-
const
|
|
192
|
+
const scoped = this.scopedKey(key);
|
|
193
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
172
194
|
return entry.count > this.maxRequests;
|
|
173
195
|
}
|
|
174
196
|
async reset(key, context) {
|
|
175
|
-
|
|
176
|
-
|
|
197
|
+
const scoped = this.scopedKey(key);
|
|
198
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
199
|
+
else await this.store.reset(scoped, context);
|
|
177
200
|
}
|
|
178
201
|
};
|
|
179
202
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -211,7 +234,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
211
234
|
|
|
212
235
|
// src/endpoints/capabilities.ts
|
|
213
236
|
function createInboundEmailEndpoint(capability, store) {
|
|
214
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
237
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
215
238
|
return {
|
|
216
239
|
path: "/support-webhook/inbound-email",
|
|
217
240
|
method: "post",
|
|
@@ -242,7 +265,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
242
265
|
};
|
|
243
266
|
}
|
|
244
267
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
245
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
268
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
246
269
|
return {
|
|
247
270
|
path: "/support/suggest-projects",
|
|
248
271
|
method: "post",
|
|
@@ -250,7 +273,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
250
273
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
251
274
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
252
275
|
}
|
|
253
|
-
const key =
|
|
276
|
+
const key = principalRateKey(req.user);
|
|
254
277
|
if (await limiter.check(key, req)) {
|
|
255
278
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
256
279
|
}
|
|
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
282
|
};
|
|
260
283
|
}
|
|
261
284
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
262
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
285
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
263
286
|
return {
|
|
264
287
|
path: "/support/ticket-title",
|
|
265
288
|
method: "post",
|
|
@@ -281,7 +304,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
281
304
|
};
|
|
282
305
|
}
|
|
283
306
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
284
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
307
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
285
308
|
return {
|
|
286
309
|
path: "/support/generate-missing-titles",
|
|
287
310
|
method: "post",
|
|
@@ -437,6 +460,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
|
437
460
|
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
438
461
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
439
462
|
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
463
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
464
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
465
|
+
if (staffSlug) return staffSlug;
|
|
466
|
+
const config = payload.config;
|
|
467
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
468
|
+
if (typeof registered === "string" && registered) return registered;
|
|
469
|
+
return config?.admin?.user || "users";
|
|
470
|
+
}
|
|
440
471
|
var DEFAULT_SETTINGS = {
|
|
441
472
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
442
473
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
@@ -448,10 +479,13 @@ var DEFAULT_USER_PREFS = {
|
|
|
448
479
|
locale: "fr",
|
|
449
480
|
signature: ""
|
|
450
481
|
};
|
|
451
|
-
var settingsCache =
|
|
482
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
452
483
|
var SETTINGS_TTL_MS = 6e4;
|
|
484
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
485
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
453
486
|
function invalidateSupportSettingsCache() {
|
|
454
|
-
settingsCache
|
|
487
|
+
settingsCache.clear();
|
|
488
|
+
warnedForeignSettingsRow.clear();
|
|
455
489
|
}
|
|
456
490
|
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
457
491
|
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
@@ -468,9 +502,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
|
468
502
|
)
|
|
469
503
|
};
|
|
470
504
|
}
|
|
471
|
-
async function readSupportSettingsState(payload) {
|
|
472
|
-
|
|
473
|
-
|
|
505
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
506
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
507
|
+
const cached = settingsCache.get(staff);
|
|
508
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
509
|
+
return cached.value;
|
|
474
510
|
}
|
|
475
511
|
let value = {
|
|
476
512
|
settings: mergeSupportSettings(null),
|
|
@@ -478,7 +514,10 @@ async function readSupportSettingsState(payload) {
|
|
|
478
514
|
};
|
|
479
515
|
try {
|
|
480
516
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
481
|
-
|
|
517
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
518
|
+
// security boundary: without it any authenticated principal can plant a
|
|
519
|
+
// `support-settings` row and own the plugin's server settings.
|
|
520
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
482
521
|
// The upsert is scoped per admin user, so several rows can share the key.
|
|
483
522
|
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
484
523
|
sort: "-updatedAt",
|
|
@@ -491,22 +530,43 @@ async function readSupportSettingsState(payload) {
|
|
|
491
530
|
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
492
531
|
const settings = mergeSupportSettings(stored);
|
|
493
532
|
if (!featuresConfigured) {
|
|
494
|
-
settings.features.roundRobin = await readLegacyRoundRobin(payload);
|
|
533
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
495
534
|
}
|
|
496
535
|
value = { settings, featuresConfigured };
|
|
536
|
+
} else {
|
|
537
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
497
538
|
}
|
|
498
539
|
} catch {
|
|
499
540
|
}
|
|
500
|
-
settingsCache
|
|
541
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
542
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
501
543
|
return value;
|
|
502
544
|
}
|
|
503
|
-
async function
|
|
504
|
-
|
|
545
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
546
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
547
|
+
try {
|
|
548
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
549
|
+
where: { key: { equals: PREF_KEY } },
|
|
550
|
+
limit: 1,
|
|
551
|
+
depth: 0,
|
|
552
|
+
overrideAccess: true
|
|
553
|
+
});
|
|
554
|
+
if (any.docs.length === 0) return;
|
|
555
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
556
|
+
warnedForeignSettingsRow.add(staff);
|
|
557
|
+
console.warn(
|
|
558
|
+
`[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
|
|
559
|
+
);
|
|
560
|
+
} catch {
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
564
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
505
565
|
}
|
|
506
|
-
async function readLegacyRoundRobin(payload) {
|
|
566
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
507
567
|
try {
|
|
508
568
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
509
|
-
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
|
|
569
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
510
570
|
limit: 1,
|
|
511
571
|
depth: 0,
|
|
512
572
|
overrideAccess: true
|
|
@@ -518,11 +578,14 @@ async function readLegacyRoundRobin(payload) {
|
|
|
518
578
|
}
|
|
519
579
|
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
520
580
|
}
|
|
521
|
-
async function readUserPrefs(payload, userId) {
|
|
581
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
522
582
|
try {
|
|
523
583
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
524
584
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
525
|
-
where: {
|
|
585
|
+
where: {
|
|
586
|
+
key: { equals: key },
|
|
587
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
588
|
+
},
|
|
526
589
|
limit: 1,
|
|
527
590
|
depth: 0,
|
|
528
591
|
overrideAccess: true
|
|
@@ -560,7 +623,7 @@ function getModel(aiSettings) {
|
|
|
560
623
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
561
624
|
}
|
|
562
625
|
function createAiEndpoint(slugs, store) {
|
|
563
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
626
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
564
627
|
return {
|
|
565
628
|
path: "/support/ai",
|
|
566
629
|
method: "post",
|
|
@@ -568,7 +631,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
568
631
|
try {
|
|
569
632
|
const payload = req.payload;
|
|
570
633
|
requireAdmin(req, slugs);
|
|
571
|
-
if (await limiter.check(
|
|
634
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
572
635
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
573
636
|
}
|
|
574
637
|
const settings = await readSupportSettings(payload);
|
|
@@ -813,14 +876,14 @@ ${kbText || "(vide)"}`;
|
|
|
813
876
|
|
|
814
877
|
// src/endpoints/ai-agent.ts
|
|
815
878
|
function createAiAgentEndpoint(slugs, store) {
|
|
816
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
879
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
817
880
|
return {
|
|
818
881
|
path: "/support/ai-agent",
|
|
819
882
|
method: "post",
|
|
820
883
|
handler: async (req) => {
|
|
821
884
|
try {
|
|
822
885
|
requireAdmin(req, slugs);
|
|
823
|
-
if (await limiter.check(
|
|
886
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
824
887
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
825
888
|
}
|
|
826
889
|
let body = {};
|
|
@@ -860,11 +923,11 @@ function getModel2(aiSettings) {
|
|
|
860
923
|
}
|
|
861
924
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
862
925
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
863
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
926
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
864
927
|
const getHandler = async (req) => {
|
|
865
928
|
try {
|
|
866
929
|
requireAdmin(req, slugs);
|
|
867
|
-
if (await limiter.check(
|
|
930
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
868
931
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
869
932
|
}
|
|
870
933
|
const payload = req.payload;
|
|
@@ -896,7 +959,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
896
959
|
const postHandler = async (req) => {
|
|
897
960
|
try {
|
|
898
961
|
requireAdmin(req, slugs);
|
|
899
|
-
if (await limiter.check(
|
|
962
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
900
963
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
901
964
|
}
|
|
902
965
|
const payload = req.payload;
|
|
@@ -1525,6 +1588,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1525
1588
|
// src/endpoints/typing.ts
|
|
1526
1589
|
var typingState = /* @__PURE__ */ new Map();
|
|
1527
1590
|
var TYPING_TTL = 5e3;
|
|
1591
|
+
var MAX_TYPING_KEYS = 500;
|
|
1592
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1593
|
+
function normalizeTicketId(raw) {
|
|
1594
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1595
|
+
if (typeof raw !== "string") return null;
|
|
1596
|
+
const value = raw.trim();
|
|
1597
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1598
|
+
}
|
|
1528
1599
|
function cleanExpired(ticketId) {
|
|
1529
1600
|
const state = typingState.get(ticketId);
|
|
1530
1601
|
if (!state) return;
|
|
@@ -1539,6 +1610,36 @@ function cleanExpired(ticketId) {
|
|
|
1539
1610
|
}
|
|
1540
1611
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1541
1612
|
}
|
|
1613
|
+
function sweepExpired() {
|
|
1614
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1615
|
+
}
|
|
1616
|
+
function evictOldest() {
|
|
1617
|
+
let oldestKey = null;
|
|
1618
|
+
let oldestTs = Infinity;
|
|
1619
|
+
for (const [key, state] of typingState) {
|
|
1620
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1621
|
+
if (ts < oldestTs) {
|
|
1622
|
+
oldestTs = ts;
|
|
1623
|
+
oldestKey = key;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1627
|
+
}
|
|
1628
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1629
|
+
const collection = req.user?.collection;
|
|
1630
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1631
|
+
try {
|
|
1632
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1633
|
+
id: ticketId,
|
|
1634
|
+
depth: 0,
|
|
1635
|
+
overrideAccess: false,
|
|
1636
|
+
user: req.user
|
|
1637
|
+
});
|
|
1638
|
+
return !!doc;
|
|
1639
|
+
} catch {
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1542
1643
|
function createTypingPostEndpoint(slugs) {
|
|
1543
1644
|
return {
|
|
1544
1645
|
path: "/support/typing",
|
|
@@ -1549,11 +1650,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1549
1650
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1550
1651
|
}
|
|
1551
1652
|
const { ticketId } = await req.json();
|
|
1552
|
-
|
|
1653
|
+
const key = normalizeTicketId(ticketId);
|
|
1654
|
+
if (!key) {
|
|
1553
1655
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1554
1656
|
}
|
|
1555
|
-
|
|
1657
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1658
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1659
|
+
}
|
|
1556
1660
|
const state = typingState.get(key) || {};
|
|
1661
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1662
|
+
sweepExpired();
|
|
1663
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1664
|
+
}
|
|
1557
1665
|
if (req.user.collection === slugs.users) {
|
|
1558
1666
|
state.admin = Date.now();
|
|
1559
1667
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1574,30 +1682,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1574
1682
|
path: "/support/typing",
|
|
1575
1683
|
method: "get",
|
|
1576
1684
|
handler: async (req) => {
|
|
1685
|
+
const idle = { typing: false, name: null };
|
|
1577
1686
|
try {
|
|
1578
1687
|
if (!req.user) {
|
|
1579
1688
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1580
1689
|
}
|
|
1581
1690
|
const url = new URL(req.url);
|
|
1582
|
-
const
|
|
1583
|
-
if (!
|
|
1691
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1692
|
+
if (!key) {
|
|
1584
1693
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1585
1694
|
}
|
|
1586
|
-
cleanExpired(
|
|
1587
|
-
const state = typingState.get(
|
|
1695
|
+
cleanExpired(key);
|
|
1696
|
+
const state = typingState.get(key);
|
|
1697
|
+
if (!state) return Response.json(idle);
|
|
1698
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1588
1699
|
if (req.user.collection === slugs.users) {
|
|
1589
1700
|
return Response.json({
|
|
1590
|
-
typing: !!state
|
|
1591
|
-
name: state
|
|
1701
|
+
typing: !!state.client,
|
|
1702
|
+
name: state.clientName || null
|
|
1592
1703
|
});
|
|
1593
1704
|
} else {
|
|
1594
1705
|
return Response.json({
|
|
1595
|
-
typing: !!state
|
|
1596
|
-
name: state
|
|
1706
|
+
typing: !!state.admin,
|
|
1707
|
+
name: state.adminName || null
|
|
1597
1708
|
});
|
|
1598
1709
|
}
|
|
1599
1710
|
} catch {
|
|
1600
|
-
return Response.json(
|
|
1711
|
+
return Response.json(idle);
|
|
1601
1712
|
}
|
|
1602
1713
|
}
|
|
1603
1714
|
};
|
|
@@ -1760,7 +1871,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1760
1871
|
const payload = req.payload;
|
|
1761
1872
|
requireAdmin(req, slugs);
|
|
1762
1873
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1763
|
-
|
|
1874
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1875
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1876
|
+
// collections — a support-client with the same id would otherwise own
|
|
1877
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1878
|
+
where: {
|
|
1879
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1880
|
+
"user.relationTo": { equals: slugs.users }
|
|
1881
|
+
},
|
|
1764
1882
|
limit: 1,
|
|
1765
1883
|
depth: 0,
|
|
1766
1884
|
overrideAccess: true
|
|
@@ -1787,7 +1905,7 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1787
1905
|
const { signature } = await req.json();
|
|
1788
1906
|
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1789
1907
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1790
|
-
where: { key: { equals: key } },
|
|
1908
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1791
1909
|
limit: 1,
|
|
1792
1910
|
depth: 0,
|
|
1793
1911
|
overrideAccess: true
|
|
@@ -2548,7 +2666,7 @@ function formatFr(date, withTime) {
|
|
|
2548
2666
|
});
|
|
2549
2667
|
}
|
|
2550
2668
|
function createSendReminderEndpoint(slugs, store) {
|
|
2551
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2669
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2552
2670
|
return {
|
|
2553
2671
|
path: "/support/send-reminder",
|
|
2554
2672
|
method: "post",
|
|
@@ -2556,7 +2674,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2556
2674
|
try {
|
|
2557
2675
|
const payload = req.payload;
|
|
2558
2676
|
requireAdmin(req, slugs);
|
|
2559
|
-
if (await reminderLimiter.check(
|
|
2677
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2560
2678
|
return Response.json(
|
|
2561
2679
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2562
2680
|
{ status: 429 }
|
|
@@ -2859,8 +2977,14 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2859
2977
|
}
|
|
2860
2978
|
|
|
2861
2979
|
// src/endpoints/chatbot.ts
|
|
2862
|
-
|
|
2863
|
-
|
|
2980
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
2981
|
+
function resolveMaxPerHour(explicit) {
|
|
2982
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
2983
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
2984
|
+
}
|
|
2985
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
2986
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
2987
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2864
2988
|
return {
|
|
2865
2989
|
path: "/support/chatbot",
|
|
2866
2990
|
method: "post",
|
|
@@ -2880,6 +3004,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2880
3004
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2881
3005
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2882
3006
|
}
|
|
3007
|
+
if (await globalLimiter.check("all", req)) {
|
|
3008
|
+
return Response.json({
|
|
3009
|
+
answer: null,
|
|
3010
|
+
confidence: 0,
|
|
3011
|
+
suggestion: "create_ticket",
|
|
3012
|
+
aiUnavailable: true,
|
|
3013
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
2883
3016
|
const payload = req.payload;
|
|
2884
3017
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2885
3018
|
where: { published: { equals: true } },
|
|
@@ -2992,8 +3125,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
2992
3125
|
};
|
|
2993
3126
|
}
|
|
2994
3127
|
function createChatPostEndpoint(slugs, store) {
|
|
2995
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
2996
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3128
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3129
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
2997
3130
|
return {
|
|
2998
3131
|
path: "/support/chat",
|
|
2999
3132
|
method: "post",
|
|
@@ -3009,8 +3142,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3009
3142
|
}
|
|
3010
3143
|
const { action, session, message } = body;
|
|
3011
3144
|
const userId = String(req.user.id);
|
|
3145
|
+
const rateKey = principalRateKey(req.user);
|
|
3012
3146
|
if (action === "start") {
|
|
3013
|
-
if (await chatSessionLimiter.check(
|
|
3147
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
3014
3148
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
3015
3149
|
}
|
|
3016
3150
|
const sessionId = `chat_${crypto3.randomBytes(16).toString("hex")}`;
|
|
@@ -3027,7 +3161,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3027
3161
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
3028
3162
|
}
|
|
3029
3163
|
if (action === "send" && session && message) {
|
|
3030
|
-
if (await chatMessageLimiter.check(
|
|
3164
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
3031
3165
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
3032
3166
|
}
|
|
3033
3167
|
const trimmedMessage = String(message).trim();
|
|
@@ -3267,7 +3401,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3267
3401
|
};
|
|
3268
3402
|
}
|
|
3269
3403
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3270
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3404
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3271
3405
|
return {
|
|
3272
3406
|
path: "/support/admin-chat",
|
|
3273
3407
|
method: "post",
|
|
@@ -3296,7 +3430,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3296
3430
|
}
|
|
3297
3431
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3298
3432
|
if (action === "send" && message) {
|
|
3299
|
-
if (await adminChatLimiter.check(
|
|
3433
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3300
3434
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3301
3435
|
}
|
|
3302
3436
|
const trimmedMessage = String(message).trim();
|
|
@@ -4251,14 +4385,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4251
4385
|
|
|
4252
4386
|
// src/endpoints/ticket-synthesis.ts
|
|
4253
4387
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4254
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4388
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4255
4389
|
return {
|
|
4256
4390
|
path: "/support/ticket-synthesis",
|
|
4257
4391
|
method: "post",
|
|
4258
4392
|
handler: async (req) => {
|
|
4259
4393
|
try {
|
|
4260
4394
|
requireAdmin(req, slugs);
|
|
4261
|
-
if (await limiter.check(
|
|
4395
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4262
4396
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4263
4397
|
}
|
|
4264
4398
|
const payload = req.payload;
|
|
@@ -4302,15 +4436,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4302
4436
|
}
|
|
4303
4437
|
|
|
4304
4438
|
// src/endpoints/email-stats.ts
|
|
4305
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4439
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4440
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4306
4441
|
return {
|
|
4307
4442
|
path: "/support/email-stats",
|
|
4308
4443
|
method: "get",
|
|
4309
4444
|
handler: async (req) => {
|
|
4310
4445
|
try {
|
|
4311
4446
|
const payload = req.payload;
|
|
4312
|
-
|
|
4313
|
-
|
|
4447
|
+
requireAdmin(req, slugs);
|
|
4448
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4449
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4314
4450
|
}
|
|
4315
4451
|
const url = new URL(req.url);
|
|
4316
4452
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4377,6 +4513,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4377
4513
|
actions: Object.fromEntries(actionMap)
|
|
4378
4514
|
});
|
|
4379
4515
|
} catch (err) {
|
|
4516
|
+
const authResponse = handleAuthError(err);
|
|
4517
|
+
if (authResponse) return authResponse;
|
|
4380
4518
|
console.error("[email-stats] Error:", err);
|
|
4381
4519
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4382
4520
|
}
|
|
@@ -4809,7 +4947,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4809
4947
|
|
|
4810
4948
|
// src/endpoints/resend-notification.ts
|
|
4811
4949
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4812
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
4950
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4813
4951
|
return {
|
|
4814
4952
|
path: "/support/resend-notification",
|
|
4815
4953
|
method: "post",
|
|
@@ -4817,7 +4955,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4817
4955
|
try {
|
|
4818
4956
|
const payload = req.payload;
|
|
4819
4957
|
requireAdmin(req, slugs);
|
|
4820
|
-
if (await resendLimiter.check(
|
|
4958
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4821
4959
|
return Response.json(
|
|
4822
4960
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4823
4961
|
{ status: 429 }
|
|
@@ -5018,10 +5156,48 @@ function createSeedKbEndpoint(slugs) {
|
|
|
5018
5156
|
}
|
|
5019
5157
|
};
|
|
5020
5158
|
}
|
|
5159
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5160
|
+
function challengeSecret() {
|
|
5161
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5162
|
+
if (!secret) {
|
|
5163
|
+
throw new Error(
|
|
5164
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5165
|
+
);
|
|
5166
|
+
}
|
|
5167
|
+
return secret;
|
|
5168
|
+
}
|
|
5169
|
+
function normalizeEmail(email) {
|
|
5170
|
+
return String(email).trim().toLowerCase();
|
|
5171
|
+
}
|
|
5172
|
+
function sign(email, expiresAt) {
|
|
5173
|
+
return createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5174
|
+
}
|
|
5175
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5176
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5177
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5178
|
+
}
|
|
5179
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5180
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5181
|
+
const separator = token.indexOf(".");
|
|
5182
|
+
if (separator <= 0) return false;
|
|
5183
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5184
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5185
|
+
const received = token.slice(separator + 1);
|
|
5186
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5187
|
+
let expected;
|
|
5188
|
+
try {
|
|
5189
|
+
expected = sign(email, expiresAt);
|
|
5190
|
+
} catch {
|
|
5191
|
+
return false;
|
|
5192
|
+
}
|
|
5193
|
+
const a = Buffer.from(expected, "hex");
|
|
5194
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5195
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
5196
|
+
}
|
|
5021
5197
|
|
|
5022
5198
|
// src/endpoints/login.ts
|
|
5023
5199
|
function createLoginEndpoint(slugs, store) {
|
|
5024
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5200
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
5025
5201
|
return {
|
|
5026
5202
|
path: "/support/login",
|
|
5027
5203
|
method: "post",
|
|
@@ -5074,7 +5250,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5074
5250
|
} catch (err) {
|
|
5075
5251
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5076
5252
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5077
|
-
|
|
5253
|
+
let challenge;
|
|
5254
|
+
try {
|
|
5255
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5256
|
+
} catch {
|
|
5257
|
+
}
|
|
5258
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5078
5259
|
}
|
|
5079
5260
|
let errorReason = "Identifiants incorrects";
|
|
5080
5261
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5106,8 +5287,8 @@ function hashCode(code) {
|
|
|
5106
5287
|
return createHmac("sha256", secret).update(code).digest("hex");
|
|
5107
5288
|
}
|
|
5108
5289
|
function createAuth2faEndpoint(slugs, store) {
|
|
5109
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5110
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5290
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5291
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5111
5292
|
return {
|
|
5112
5293
|
path: "/support/2fa",
|
|
5113
5294
|
method: "post",
|
|
@@ -5120,12 +5301,18 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5120
5301
|
} catch {
|
|
5121
5302
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5122
5303
|
}
|
|
5123
|
-
const { action, email, code } = body;
|
|
5304
|
+
const { action, email, code, challenge } = body;
|
|
5124
5305
|
if (!action || !email) {
|
|
5125
5306
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5126
5307
|
}
|
|
5127
5308
|
const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
|
|
5128
5309
|
if (action === "send") {
|
|
5310
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5311
|
+
return Response.json(
|
|
5312
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5313
|
+
{ status: 401 }
|
|
5314
|
+
);
|
|
5315
|
+
}
|
|
5129
5316
|
if (await sendLimiter.check(email, req)) {
|
|
5130
5317
|
return Response.json(genericSendResponse);
|
|
5131
5318
|
}
|
|
@@ -5219,6 +5406,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5219
5406
|
}
|
|
5220
5407
|
};
|
|
5221
5408
|
}
|
|
5409
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5410
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5411
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5412
|
+
function readCookie(header, name) {
|
|
5413
|
+
if (!header) return null;
|
|
5414
|
+
for (const part of header.split(";")) {
|
|
5415
|
+
const eq = part.indexOf("=");
|
|
5416
|
+
if (eq === -1) continue;
|
|
5417
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5418
|
+
try {
|
|
5419
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5420
|
+
} catch {
|
|
5421
|
+
return part.slice(eq + 1).trim();
|
|
5422
|
+
}
|
|
5423
|
+
}
|
|
5424
|
+
return null;
|
|
5425
|
+
}
|
|
5426
|
+
function clearedStateCookie() {
|
|
5427
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5428
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5429
|
+
}
|
|
5430
|
+
function statesMatch(a, b) {
|
|
5431
|
+
if (!a || !b) return false;
|
|
5432
|
+
const left = crypto3.createHash("sha256").update(a).digest();
|
|
5433
|
+
const right = crypto3.createHash("sha256").update(b).digest();
|
|
5434
|
+
return crypto3.timingSafeEqual(left, right);
|
|
5435
|
+
}
|
|
5222
5436
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5223
5437
|
return {
|
|
5224
5438
|
path: "/support/oauth/google",
|
|
@@ -5235,7 +5449,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5235
5449
|
}
|
|
5236
5450
|
try {
|
|
5237
5451
|
const body = await req.json();
|
|
5238
|
-
const { action, code, state: queryState
|
|
5452
|
+
const { action, code, state: queryState } = body;
|
|
5239
5453
|
if (action === "login") {
|
|
5240
5454
|
const oauthState = crypto3.randomBytes(32).toString("hex");
|
|
5241
5455
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5247,13 +5461,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5247
5461
|
state: oauthState,
|
|
5248
5462
|
prompt: "select_account"
|
|
5249
5463
|
});
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5464
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5465
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5466
|
+
loginHeaders.append(
|
|
5467
|
+
"Set-Cookie",
|
|
5468
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5469
|
+
);
|
|
5470
|
+
return new Response(
|
|
5471
|
+
JSON.stringify({
|
|
5472
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5473
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5474
|
+
// server no longer trusts anything the caller returns.
|
|
5475
|
+
state: oauthState
|
|
5476
|
+
}),
|
|
5477
|
+
{ status: 200, headers: loginHeaders }
|
|
5478
|
+
);
|
|
5254
5479
|
}
|
|
5255
5480
|
if (code) {
|
|
5256
|
-
|
|
5481
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5482
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5257
5483
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5258
5484
|
}
|
|
5259
5485
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5335,6 +5561,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5335
5561
|
overrideAccess: true
|
|
5336
5562
|
});
|
|
5337
5563
|
}
|
|
5564
|
+
const twoFactorDoc = await dbFindByID(payload, slugs.supportClients, {
|
|
5565
|
+
id: clientDoc.id,
|
|
5566
|
+
depth: 0,
|
|
5567
|
+
overrideAccess: true,
|
|
5568
|
+
showHiddenFields: true
|
|
5569
|
+
});
|
|
5570
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5571
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5572
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5573
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5574
|
+
let challenge;
|
|
5575
|
+
try {
|
|
5576
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5577
|
+
} catch {
|
|
5578
|
+
}
|
|
5579
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5580
|
+
status: 200,
|
|
5581
|
+
headers: new Headers({
|
|
5582
|
+
"Content-Type": "application/json",
|
|
5583
|
+
"Set-Cookie": clearedStateCookie()
|
|
5584
|
+
})
|
|
5585
|
+
});
|
|
5586
|
+
}
|
|
5587
|
+
await dbUpdate(payload, slugs.supportClients, {
|
|
5588
|
+
id: clientDoc.id,
|
|
5589
|
+
data: { twoFactorVerifiedAt: null },
|
|
5590
|
+
overrideAccess: true
|
|
5591
|
+
});
|
|
5592
|
+
}
|
|
5338
5593
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5339
5594
|
if (!secret) {
|
|
5340
5595
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5378,6 +5633,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5378
5633
|
"Set-Cookie",
|
|
5379
5634
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5380
5635
|
);
|
|
5636
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5381
5637
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5382
5638
|
status: 200,
|
|
5383
5639
|
headers
|
|
@@ -5684,7 +5940,7 @@ ONLY JSON, nothing else.`
|
|
|
5684
5940
|
}
|
|
5685
5941
|
}
|
|
5686
5942
|
function createImportConversationEndpoint(slugs, store) {
|
|
5687
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
5943
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5688
5944
|
return {
|
|
5689
5945
|
path: "/support/import-conversation",
|
|
5690
5946
|
method: "post",
|
|
@@ -5827,6 +6083,137 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5827
6083
|
}
|
|
5828
6084
|
};
|
|
5829
6085
|
}
|
|
6086
|
+
function httpAllowed() {
|
|
6087
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6088
|
+
}
|
|
6089
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6090
|
+
function parseIPv4(host) {
|
|
6091
|
+
const parts = host.split(".");
|
|
6092
|
+
if (parts.length !== 4) return null;
|
|
6093
|
+
const octets = [];
|
|
6094
|
+
for (const part of parts) {
|
|
6095
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6096
|
+
const n = Number(part);
|
|
6097
|
+
if (n > 255) return null;
|
|
6098
|
+
octets.push(n);
|
|
6099
|
+
}
|
|
6100
|
+
return octets;
|
|
6101
|
+
}
|
|
6102
|
+
function isPrivateIPv4(octets) {
|
|
6103
|
+
const [a, b] = octets;
|
|
6104
|
+
if (a === 0) return true;
|
|
6105
|
+
if (a === 10) return true;
|
|
6106
|
+
if (a === 127) return true;
|
|
6107
|
+
if (a === 169 && b === 254) return true;
|
|
6108
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6109
|
+
if (a === 192 && b === 168) return true;
|
|
6110
|
+
if (a === 192 && b === 0) return true;
|
|
6111
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6112
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6113
|
+
if (a >= 224) return true;
|
|
6114
|
+
return false;
|
|
6115
|
+
}
|
|
6116
|
+
function isPrivateIPv6(host) {
|
|
6117
|
+
const lower = host.toLowerCase();
|
|
6118
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6119
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6120
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6121
|
+
if (lower.startsWith("ff")) return true;
|
|
6122
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6123
|
+
if (dotted) {
|
|
6124
|
+
const octets = parseIPv4(dotted[1]);
|
|
6125
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6126
|
+
}
|
|
6127
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6128
|
+
if (hex) {
|
|
6129
|
+
const high = parseInt(hex[1], 16);
|
|
6130
|
+
const low = parseInt(hex[2], 16);
|
|
6131
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6132
|
+
}
|
|
6133
|
+
return false;
|
|
6134
|
+
}
|
|
6135
|
+
function normalizeHost(hostname) {
|
|
6136
|
+
const host = hostname.trim().toLowerCase();
|
|
6137
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6138
|
+
}
|
|
6139
|
+
function isBlockedHost(hostname) {
|
|
6140
|
+
const host = normalizeHost(hostname);
|
|
6141
|
+
if (!host) return true;
|
|
6142
|
+
if (host === "localhost") return true;
|
|
6143
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6144
|
+
const v4 = parseIPv4(host);
|
|
6145
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6146
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6147
|
+
return false;
|
|
6148
|
+
}
|
|
6149
|
+
function validateWebhookUrl(raw) {
|
|
6150
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6151
|
+
let url;
|
|
6152
|
+
try {
|
|
6153
|
+
url = new URL(raw.trim());
|
|
6154
|
+
} catch {
|
|
6155
|
+
return { ok: false, reason: "invalid_url" };
|
|
6156
|
+
}
|
|
6157
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6158
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6159
|
+
}
|
|
6160
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6161
|
+
return { ok: true, url };
|
|
6162
|
+
}
|
|
6163
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6164
|
+
invalid_url: "URL invalide.",
|
|
6165
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6166
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6167
|
+
};
|
|
6168
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6169
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6170
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6171
|
+
async function assertPublicHost(hostname) {
|
|
6172
|
+
const host = normalizeHost(hostname);
|
|
6173
|
+
if (isBlockedHost(host)) return false;
|
|
6174
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6175
|
+
try {
|
|
6176
|
+
const addresses = await Promise.race([
|
|
6177
|
+
lookup(host, { all: true }),
|
|
6178
|
+
new Promise(
|
|
6179
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6180
|
+
)
|
|
6181
|
+
]);
|
|
6182
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6183
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6184
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6185
|
+
} catch (error) {
|
|
6186
|
+
const code = error?.code;
|
|
6187
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6188
|
+
}
|
|
6189
|
+
}
|
|
6190
|
+
var BlockedRequestError = class extends Error {
|
|
6191
|
+
constructor(reason) {
|
|
6192
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6193
|
+
this.reason = reason;
|
|
6194
|
+
this.name = "BlockedRequestError";
|
|
6195
|
+
}
|
|
6196
|
+
reason;
|
|
6197
|
+
};
|
|
6198
|
+
var MAX_REDIRECTS = 3;
|
|
6199
|
+
async function safeFetch(rawUrl, init) {
|
|
6200
|
+
let current = rawUrl;
|
|
6201
|
+
let body = init.body;
|
|
6202
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6203
|
+
const validation = validateWebhookUrl(current);
|
|
6204
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6205
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6206
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6207
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6208
|
+
const location = response.headers.get("location");
|
|
6209
|
+
if (!location) return response;
|
|
6210
|
+
current = new URL(location, current).toString();
|
|
6211
|
+
if (response.status === 303) body = void 0;
|
|
6212
|
+
}
|
|
6213
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6214
|
+
}
|
|
6215
|
+
|
|
6216
|
+
// src/utils/webhookDispatcher.ts
|
|
5830
6217
|
function dispatchWebhook(data, event, payload, slugs) {
|
|
5831
6218
|
const done = _dispatch(data, event, payload, slugs);
|
|
5832
6219
|
return done;
|
|
@@ -5863,7 +6250,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
|
5863
6250
|
const signature = crypto3.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
5864
6251
|
headers["X-Webhook-Signature"] = signature;
|
|
5865
6252
|
}
|
|
5866
|
-
const response = await
|
|
6253
|
+
const response = await safeFetch(endpoint.url, {
|
|
5867
6254
|
method: "POST",
|
|
5868
6255
|
headers,
|
|
5869
6256
|
body,
|
|
@@ -6317,7 +6704,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6317
6704
|
requireAdmin(req, slugs);
|
|
6318
6705
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6319
6706
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6320
|
-
where: { key: { equals: key } },
|
|
6707
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6321
6708
|
limit: 1,
|
|
6322
6709
|
depth: 0,
|
|
6323
6710
|
overrideAccess: true
|
|
@@ -6351,7 +6738,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6351
6738
|
const body = await req.json();
|
|
6352
6739
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6353
6740
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6354
|
-
where: { key: { equals: key } },
|
|
6741
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6355
6742
|
limit: 1,
|
|
6356
6743
|
depth: 0,
|
|
6357
6744
|
overrideAccess: true
|
|
@@ -6507,8 +6894,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6507
6894
|
// src/endpoints/transfer-ticket.ts
|
|
6508
6895
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6509
6896
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
6897
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6510
6898
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6511
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
6899
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
6900
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6512
6901
|
return {
|
|
6513
6902
|
path: "/support/tickets/:id/transfer",
|
|
6514
6903
|
method: "post",
|
|
@@ -6543,12 +6932,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6543
6932
|
if (!isAdmin && !isOwner) {
|
|
6544
6933
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6545
6934
|
}
|
|
6546
|
-
if (await transferLimiter.check(`${req.user
|
|
6935
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6547
6936
|
return Response.json(
|
|
6548
6937
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6549
6938
|
{ status: 429 }
|
|
6550
6939
|
);
|
|
6551
6940
|
}
|
|
6941
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
6942
|
+
return Response.json(
|
|
6943
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
6944
|
+
{ status: 429 }
|
|
6945
|
+
);
|
|
6946
|
+
}
|
|
6552
6947
|
try {
|
|
6553
6948
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6554
6949
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6690,8 +7085,11 @@ function statusToLabel(status) {
|
|
|
6690
7085
|
}
|
|
6691
7086
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6692
7087
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7088
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7089
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6693
7090
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6694
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7091
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7092
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6695
7093
|
return {
|
|
6696
7094
|
path: "/support/tickets/:id/invite",
|
|
6697
7095
|
method: "post",
|
|
@@ -6726,7 +7124,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6726
7124
|
if (!isAdmin && !isOwner) {
|
|
6727
7125
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6728
7126
|
}
|
|
6729
|
-
if (await inviteLimiter.check(
|
|
7127
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6730
7128
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6731
7129
|
}
|
|
6732
7130
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6750,6 +7148,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6750
7148
|
if (existing.docs.length > 0) {
|
|
6751
7149
|
inviteeId = existing.docs[0].id;
|
|
6752
7150
|
} else {
|
|
7151
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7152
|
+
return Response.json(
|
|
7153
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7154
|
+
{ status: 429 }
|
|
7155
|
+
);
|
|
7156
|
+
}
|
|
6753
7157
|
const tempPassword = randomBytes(16).toString("hex");
|
|
6754
7158
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6755
7159
|
data: {
|
|
@@ -6921,7 +7325,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6921
7325
|
}
|
|
6922
7326
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6923
7327
|
if (!f || f.emailTracking !== false) {
|
|
6924
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7328
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6925
7329
|
}
|
|
6926
7330
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6927
7331
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -8406,7 +8810,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8406
8810
|
}
|
|
8407
8811
|
|
|
8408
8812
|
// src/utils/ticketAccess.ts
|
|
8409
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8813
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8410
8814
|
const ids = /* @__PURE__ */ new Set();
|
|
8411
8815
|
try {
|
|
8412
8816
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8427,6 +8831,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8427
8831
|
});
|
|
8428
8832
|
for (const r of collab.docs) {
|
|
8429
8833
|
const row = r;
|
|
8834
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8430
8835
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8431
8836
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8432
8837
|
}
|
|
@@ -8533,7 +8938,7 @@ function createRestrictClientTicketTarget(slugs) {
|
|
|
8533
8938
|
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8534
8939
|
throw new APIError("Ticket cible requis.", 400);
|
|
8535
8940
|
}
|
|
8536
|
-
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
|
|
8941
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8537
8942
|
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8538
8943
|
throw new APIError("Ticket inaccessible.", 403);
|
|
8539
8944
|
}
|
|
@@ -9050,13 +9455,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9050
9455
|
return doc;
|
|
9051
9456
|
};
|
|
9052
9457
|
}
|
|
9053
|
-
var
|
|
9458
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9054
9459
|
function createEnforce2FA(slugs) {
|
|
9055
9460
|
return async ({ req, user }) => {
|
|
9056
9461
|
if (!user?.twoFactorEnabled) return user;
|
|
9057
9462
|
const raw = user.twoFactorVerifiedAt;
|
|
9058
9463
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9059
|
-
if (!(verifiedAt > Date.now() -
|
|
9464
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9060
9465
|
throw new APIError("2FA_REQUIRED", 401);
|
|
9061
9466
|
}
|
|
9062
9467
|
await req.payload.update({
|
|
@@ -9897,8 +10302,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9897
10302
|
timestamps: true
|
|
9898
10303
|
};
|
|
9899
10304
|
}
|
|
9900
|
-
|
|
9901
|
-
|
|
10305
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10306
|
+
return async ({ data, req }) => {
|
|
10307
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10308
|
+
data.client = req.user.id;
|
|
10309
|
+
data.senderType = "client";
|
|
10310
|
+
delete data.agent;
|
|
10311
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10312
|
+
if (!session) return data;
|
|
10313
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10314
|
+
where: { session: { equals: session } },
|
|
10315
|
+
limit: 1,
|
|
10316
|
+
depth: 0,
|
|
10317
|
+
overrideAccess: true
|
|
10318
|
+
});
|
|
10319
|
+
const owner = existing.docs[0]?.client;
|
|
10320
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10321
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10322
|
+
throw new APIError("Session inaccessible.", 403);
|
|
10323
|
+
}
|
|
10324
|
+
return data;
|
|
10325
|
+
};
|
|
10326
|
+
}
|
|
9902
10327
|
function createChatMessagesCollection(slugs) {
|
|
9903
10328
|
return {
|
|
9904
10329
|
slug: slugs.chatMessages,
|
|
@@ -9920,7 +10345,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9920
10345
|
}
|
|
9921
10346
|
return false;
|
|
9922
10347
|
},
|
|
9923
|
-
|
|
10348
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10349
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10350
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9924
10351
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9925
10352
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9926
10353
|
},
|
|
@@ -9989,6 +10416,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9989
10416
|
}
|
|
9990
10417
|
}
|
|
9991
10418
|
],
|
|
10419
|
+
hooks: {
|
|
10420
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10421
|
+
},
|
|
9992
10422
|
timestamps: true
|
|
9993
10423
|
};
|
|
9994
10424
|
}
|
|
@@ -10266,8 +10696,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10266
10696
|
timestamps: true
|
|
10267
10697
|
};
|
|
10268
10698
|
}
|
|
10269
|
-
|
|
10270
|
-
|
|
10699
|
+
function createValidateWebhookUrl() {
|
|
10700
|
+
return ({ data, operation, originalDoc }) => {
|
|
10701
|
+
const incoming = data?.url;
|
|
10702
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10703
|
+
const previous = originalDoc?.url;
|
|
10704
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10705
|
+
const result = validateWebhookUrl(incoming);
|
|
10706
|
+
if (!result.ok) {
|
|
10707
|
+
throw new APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10708
|
+
}
|
|
10709
|
+
return data;
|
|
10710
|
+
};
|
|
10711
|
+
}
|
|
10271
10712
|
function createWebhookEndpointsCollection(slugs) {
|
|
10272
10713
|
return {
|
|
10273
10714
|
slug: slugs.webhookEndpoints,
|
|
@@ -10301,8 +10742,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10301
10742
|
type: "text",
|
|
10302
10743
|
required: true,
|
|
10303
10744
|
label: "URL",
|
|
10745
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10746
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10747
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10304
10748
|
admin: {
|
|
10305
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10749
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10306
10750
|
}
|
|
10307
10751
|
},
|
|
10308
10752
|
{
|
|
@@ -10356,6 +10800,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10356
10800
|
}
|
|
10357
10801
|
}
|
|
10358
10802
|
],
|
|
10803
|
+
hooks: {
|
|
10804
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10805
|
+
},
|
|
10359
10806
|
timestamps: true
|
|
10360
10807
|
};
|
|
10361
10808
|
}
|
|
@@ -10807,11 +11254,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10807
11254
|
admin: { readOnly: true }
|
|
10808
11255
|
}
|
|
10809
11256
|
],
|
|
11257
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11258
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11259
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11260
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11261
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11262
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10810
11263
|
access: {
|
|
10811
|
-
create: ({ req }) => req.user?.collection ===
|
|
10812
|
-
read: ({ req }) => req.user?.collection ===
|
|
10813
|
-
update: ({ req }) => req.user?.collection ===
|
|
10814
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11264
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11265
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11266
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11267
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10815
11268
|
},
|
|
10816
11269
|
timestamps: true
|
|
10817
11270
|
};
|
|
@@ -11275,6 +11728,17 @@ function supportPlugin(config) {
|
|
|
11275
11728
|
});
|
|
11276
11729
|
return {
|
|
11277
11730
|
...incomingConfig,
|
|
11731
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11732
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11733
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11734
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11735
|
+
// auth collection of the host app — a different collection on any app
|
|
11736
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11737
|
+
// reopened right there.
|
|
11738
|
+
custom: {
|
|
11739
|
+
...incomingConfig.custom,
|
|
11740
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11741
|
+
},
|
|
11278
11742
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11279
11743
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11280
11744
|
admin: {
|