@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.cjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
var payload = require('payload');
|
|
4
4
|
var crypto3 = require('crypto');
|
|
5
5
|
var PDFDocument = require('pdfkit');
|
|
6
|
+
var promises = require('dns/promises');
|
|
6
7
|
var webpush = require('web-push');
|
|
7
8
|
var sanitizeHtml = require('sanitize-html');
|
|
8
9
|
|
|
@@ -167,22 +168,44 @@ var PayloadRateLimitStore = class {
|
|
|
167
168
|
return { payload: context };
|
|
168
169
|
}
|
|
169
170
|
};
|
|
171
|
+
function principalRateKey(user) {
|
|
172
|
+
if (!user || user.id === void 0 || user.id === null) return "anonymous";
|
|
173
|
+
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
174
|
+
return `${collection}:${String(user.id)}`;
|
|
175
|
+
}
|
|
170
176
|
var RateLimiter = class {
|
|
171
|
-
|
|
177
|
+
/**
|
|
178
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
179
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
180
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
181
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
182
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
183
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
184
|
+
* `RateLimiter` is part of the published API surface.
|
|
185
|
+
*/
|
|
186
|
+
constructor(windowMs, maxRequests, store, namespace) {
|
|
172
187
|
this.windowMs = windowMs;
|
|
173
188
|
this.maxRequests = maxRequests;
|
|
174
189
|
this.store = store ?? new MemoryRateLimitStore();
|
|
190
|
+
this.prefix = namespace ? `${namespace}:` : "";
|
|
175
191
|
}
|
|
176
192
|
windowMs;
|
|
177
193
|
maxRequests;
|
|
178
194
|
store;
|
|
195
|
+
prefix;
|
|
196
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
197
|
+
scopedKey(key) {
|
|
198
|
+
return `${this.prefix}${key}`;
|
|
199
|
+
}
|
|
179
200
|
async check(key, context) {
|
|
180
|
-
const
|
|
201
|
+
const scoped = this.scopedKey(key);
|
|
202
|
+
const entry = context === void 0 ? await this.store.increment(scoped, this.windowMs) : await this.store.increment(scoped, this.windowMs, context);
|
|
181
203
|
return entry.count > this.maxRequests;
|
|
182
204
|
}
|
|
183
205
|
async reset(key, context) {
|
|
184
|
-
|
|
185
|
-
|
|
206
|
+
const scoped = this.scopedKey(key);
|
|
207
|
+
if (context === void 0) await this.store.reset(scoped);
|
|
208
|
+
else await this.store.reset(scoped, context);
|
|
186
209
|
}
|
|
187
210
|
};
|
|
188
211
|
var DEFAULT_INBOUND_EMAIL_LIMITS = {
|
|
@@ -220,7 +243,7 @@ function validateInboundEmailPayload(input, contentLength, limits = DEFAULT_INBO
|
|
|
220
243
|
|
|
221
244
|
// src/endpoints/capabilities.ts
|
|
222
245
|
function createInboundEmailEndpoint(capability, store) {
|
|
223
|
-
const limiter = new RateLimiter(6e4, 60, store);
|
|
246
|
+
const limiter = new RateLimiter(6e4, 60, store, "inbound-email");
|
|
224
247
|
return {
|
|
225
248
|
path: "/support-webhook/inbound-email",
|
|
226
249
|
method: "post",
|
|
@@ -251,7 +274,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
251
274
|
};
|
|
252
275
|
}
|
|
253
276
|
function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
254
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
277
|
+
const limiter = new RateLimiter(6e4, 20, store, "suggest-projects");
|
|
255
278
|
return {
|
|
256
279
|
path: "/support/suggest-projects",
|
|
257
280
|
method: "post",
|
|
@@ -259,7 +282,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
259
282
|
if (!req.user || req.user.collection !== slugs.users) {
|
|
260
283
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
261
284
|
}
|
|
262
|
-
const key =
|
|
285
|
+
const key = principalRateKey(req.user);
|
|
263
286
|
if (await limiter.check(key, req)) {
|
|
264
287
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
265
288
|
}
|
|
@@ -268,7 +291,7 @@ function createProjectSuggestionsEndpoint(slugs, capability, store) {
|
|
|
268
291
|
};
|
|
269
292
|
}
|
|
270
293
|
function createTicketTitleEndpoint(slugs, capability, store) {
|
|
271
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
294
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-title");
|
|
272
295
|
return {
|
|
273
296
|
path: "/support/ticket-title",
|
|
274
297
|
method: "post",
|
|
@@ -290,7 +313,7 @@ function createTicketTitleEndpoint(slugs, capability, store) {
|
|
|
290
313
|
};
|
|
291
314
|
}
|
|
292
315
|
function createGenerateMissingTitlesEndpoint(slugs, capability, store) {
|
|
293
|
-
const limiter = new RateLimiter(6e4, 5, store);
|
|
316
|
+
const limiter = new RateLimiter(6e4, 5, store, "generate-missing-titles");
|
|
294
317
|
return {
|
|
295
318
|
path: "/support/generate-missing-titles",
|
|
296
319
|
method: "post",
|
|
@@ -446,6 +469,14 @@ var SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
|
446
469
|
var PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
447
470
|
var USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
448
471
|
var LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
472
|
+
var SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
473
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
474
|
+
if (staffSlug) return staffSlug;
|
|
475
|
+
const config = payload.config;
|
|
476
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
477
|
+
if (typeof registered === "string" && registered) return registered;
|
|
478
|
+
return config?.admin?.user || "users";
|
|
479
|
+
}
|
|
449
480
|
var DEFAULT_SETTINGS = {
|
|
450
481
|
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
451
482
|
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
@@ -457,10 +488,13 @@ var DEFAULT_USER_PREFS = {
|
|
|
457
488
|
locale: "fr",
|
|
458
489
|
signature: ""
|
|
459
490
|
};
|
|
460
|
-
var settingsCache =
|
|
491
|
+
var settingsCache = /* @__PURE__ */ new Map();
|
|
461
492
|
var SETTINGS_TTL_MS = 6e4;
|
|
493
|
+
var SETTINGS_CACHE_MAX = 8;
|
|
494
|
+
var warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
462
495
|
function invalidateSupportSettingsCache() {
|
|
463
|
-
settingsCache
|
|
496
|
+
settingsCache.clear();
|
|
497
|
+
warnedForeignSettingsRow.clear();
|
|
464
498
|
}
|
|
465
499
|
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
466
500
|
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
@@ -477,9 +511,11 @@ function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
|
477
511
|
)
|
|
478
512
|
};
|
|
479
513
|
}
|
|
480
|
-
async function readSupportSettingsState(payload) {
|
|
481
|
-
|
|
482
|
-
|
|
514
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
515
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
516
|
+
const cached = settingsCache.get(staff);
|
|
517
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
518
|
+
return cached.value;
|
|
483
519
|
}
|
|
484
520
|
let value = {
|
|
485
521
|
settings: mergeSupportSettings(null),
|
|
@@ -487,7 +523,10 @@ async function readSupportSettingsState(payload) {
|
|
|
487
523
|
};
|
|
488
524
|
try {
|
|
489
525
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
490
|
-
|
|
526
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
527
|
+
// security boundary: without it any authenticated principal can plant a
|
|
528
|
+
// `support-settings` row and own the plugin's server settings.
|
|
529
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
491
530
|
// The upsert is scoped per admin user, so several rows can share the key.
|
|
492
531
|
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
493
532
|
sort: "-updatedAt",
|
|
@@ -500,22 +539,43 @@ async function readSupportSettingsState(payload) {
|
|
|
500
539
|
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
501
540
|
const settings = mergeSupportSettings(stored);
|
|
502
541
|
if (!featuresConfigured) {
|
|
503
|
-
settings.features.roundRobin = await readLegacyRoundRobin(payload);
|
|
542
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
504
543
|
}
|
|
505
544
|
value = { settings, featuresConfigured };
|
|
545
|
+
} else {
|
|
546
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
506
547
|
}
|
|
507
548
|
} catch {
|
|
508
549
|
}
|
|
509
|
-
settingsCache
|
|
550
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
551
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
510
552
|
return value;
|
|
511
553
|
}
|
|
512
|
-
async function
|
|
513
|
-
|
|
554
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
555
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
556
|
+
try {
|
|
557
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
558
|
+
where: { key: { equals: PREF_KEY } },
|
|
559
|
+
limit: 1,
|
|
560
|
+
depth: 0,
|
|
561
|
+
overrideAccess: true
|
|
562
|
+
});
|
|
563
|
+
if (any.docs.length === 0) return;
|
|
564
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
565
|
+
warnedForeignSettingsRow.add(staff);
|
|
566
|
+
console.warn(
|
|
567
|
+
`[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.`
|
|
568
|
+
);
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
573
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
514
574
|
}
|
|
515
|
-
async function readLegacyRoundRobin(payload) {
|
|
575
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
516
576
|
try {
|
|
517
577
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
518
|
-
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
|
|
578
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
519
579
|
limit: 1,
|
|
520
580
|
depth: 0,
|
|
521
581
|
overrideAccess: true
|
|
@@ -527,11 +587,14 @@ async function readLegacyRoundRobin(payload) {
|
|
|
527
587
|
}
|
|
528
588
|
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
529
589
|
}
|
|
530
|
-
async function readUserPrefs(payload, userId) {
|
|
590
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
531
591
|
try {
|
|
532
592
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
533
593
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
534
|
-
where: {
|
|
594
|
+
where: {
|
|
595
|
+
key: { equals: key },
|
|
596
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
597
|
+
},
|
|
535
598
|
limit: 1,
|
|
536
599
|
depth: 0,
|
|
537
600
|
overrideAccess: true
|
|
@@ -569,7 +632,7 @@ function getModel(aiSettings) {
|
|
|
569
632
|
return aiSettings.model || "claude-haiku-4-5-20251001";
|
|
570
633
|
}
|
|
571
634
|
function createAiEndpoint(slugs, store) {
|
|
572
|
-
const limiter = new RateLimiter(6e4, 30, store);
|
|
635
|
+
const limiter = new RateLimiter(6e4, 30, store, "ai");
|
|
573
636
|
return {
|
|
574
637
|
path: "/support/ai",
|
|
575
638
|
method: "post",
|
|
@@ -577,7 +640,7 @@ function createAiEndpoint(slugs, store) {
|
|
|
577
640
|
try {
|
|
578
641
|
const payload = req.payload;
|
|
579
642
|
requireAdmin(req, slugs);
|
|
580
|
-
if (await limiter.check(
|
|
643
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
581
644
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
582
645
|
}
|
|
583
646
|
const settings = await readSupportSettings(payload);
|
|
@@ -822,14 +885,14 @@ ${kbText || "(vide)"}`;
|
|
|
822
885
|
|
|
823
886
|
// src/endpoints/ai-agent.ts
|
|
824
887
|
function createAiAgentEndpoint(slugs, store) {
|
|
825
|
-
const limiter = new RateLimiter(6e4, 10, store);
|
|
888
|
+
const limiter = new RateLimiter(6e4, 10, store, "ai-agent");
|
|
826
889
|
return {
|
|
827
890
|
path: "/support/ai-agent",
|
|
828
891
|
method: "post",
|
|
829
892
|
handler: async (req) => {
|
|
830
893
|
try {
|
|
831
894
|
requireAdmin(req, slugs);
|
|
832
|
-
if (await limiter.check(
|
|
895
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
833
896
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
834
897
|
}
|
|
835
898
|
let body = {};
|
|
@@ -869,11 +932,11 @@ function getModel2(aiSettings) {
|
|
|
869
932
|
}
|
|
870
933
|
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
871
934
|
function createClientIntelligenceEndpoint(slugs, store) {
|
|
872
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
935
|
+
const limiter = new RateLimiter(6e4, 20, store, "client-intelligence");
|
|
873
936
|
const getHandler = async (req) => {
|
|
874
937
|
try {
|
|
875
938
|
requireAdmin(req, slugs);
|
|
876
|
-
if (await limiter.check(
|
|
939
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
877
940
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
878
941
|
}
|
|
879
942
|
const payload = req.payload;
|
|
@@ -905,7 +968,7 @@ function createClientIntelligenceEndpoint(slugs, store) {
|
|
|
905
968
|
const postHandler = async (req) => {
|
|
906
969
|
try {
|
|
907
970
|
requireAdmin(req, slugs);
|
|
908
|
-
if (await limiter.check(
|
|
971
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
909
972
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
910
973
|
}
|
|
911
974
|
const payload = req.payload;
|
|
@@ -1534,6 +1597,14 @@ function createSplitTicketEndpoint(slugs) {
|
|
|
1534
1597
|
// src/endpoints/typing.ts
|
|
1535
1598
|
var typingState = /* @__PURE__ */ new Map();
|
|
1536
1599
|
var TYPING_TTL = 5e3;
|
|
1600
|
+
var MAX_TYPING_KEYS = 500;
|
|
1601
|
+
var TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1602
|
+
function normalizeTicketId(raw) {
|
|
1603
|
+
if (typeof raw === "number") return Number.isInteger(raw) && raw > 0 ? String(raw) : null;
|
|
1604
|
+
if (typeof raw !== "string") return null;
|
|
1605
|
+
const value = raw.trim();
|
|
1606
|
+
return TICKET_ID_PATTERN.test(value) ? value : null;
|
|
1607
|
+
}
|
|
1537
1608
|
function cleanExpired(ticketId) {
|
|
1538
1609
|
const state = typingState.get(ticketId);
|
|
1539
1610
|
if (!state) return;
|
|
@@ -1548,6 +1619,36 @@ function cleanExpired(ticketId) {
|
|
|
1548
1619
|
}
|
|
1549
1620
|
if (!state.admin && !state.client) typingState.delete(ticketId);
|
|
1550
1621
|
}
|
|
1622
|
+
function sweepExpired() {
|
|
1623
|
+
for (const key of Array.from(typingState.keys())) cleanExpired(key);
|
|
1624
|
+
}
|
|
1625
|
+
function evictOldest() {
|
|
1626
|
+
let oldestKey = null;
|
|
1627
|
+
let oldestTs = Infinity;
|
|
1628
|
+
for (const [key, state] of typingState) {
|
|
1629
|
+
const ts = Math.max(state.admin || 0, state.client || 0);
|
|
1630
|
+
if (ts < oldestTs) {
|
|
1631
|
+
oldestTs = ts;
|
|
1632
|
+
oldestKey = key;
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
if (oldestKey !== null) typingState.delete(oldestKey);
|
|
1636
|
+
}
|
|
1637
|
+
async function mayAccessTicket(req, slugs, ticketId) {
|
|
1638
|
+
const collection = req.user?.collection;
|
|
1639
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) return false;
|
|
1640
|
+
try {
|
|
1641
|
+
const doc = await dbFindByID(req.payload, slugs.tickets, {
|
|
1642
|
+
id: ticketId,
|
|
1643
|
+
depth: 0,
|
|
1644
|
+
overrideAccess: false,
|
|
1645
|
+
user: req.user
|
|
1646
|
+
});
|
|
1647
|
+
return !!doc;
|
|
1648
|
+
} catch {
|
|
1649
|
+
return false;
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1551
1652
|
function createTypingPostEndpoint(slugs) {
|
|
1552
1653
|
return {
|
|
1553
1654
|
path: "/support/typing",
|
|
@@ -1558,11 +1659,18 @@ function createTypingPostEndpoint(slugs) {
|
|
|
1558
1659
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1559
1660
|
}
|
|
1560
1661
|
const { ticketId } = await req.json();
|
|
1561
|
-
|
|
1662
|
+
const key = normalizeTicketId(ticketId);
|
|
1663
|
+
if (!key) {
|
|
1562
1664
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1563
1665
|
}
|
|
1564
|
-
|
|
1666
|
+
if (!await mayAccessTicket(req, slugs, key)) {
|
|
1667
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
1668
|
+
}
|
|
1565
1669
|
const state = typingState.get(key) || {};
|
|
1670
|
+
if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
|
|
1671
|
+
sweepExpired();
|
|
1672
|
+
if (typingState.size >= MAX_TYPING_KEYS) evictOldest();
|
|
1673
|
+
}
|
|
1566
1674
|
if (req.user.collection === slugs.users) {
|
|
1567
1675
|
state.admin = Date.now();
|
|
1568
1676
|
state.adminName = req.user.firstName || "Support";
|
|
@@ -1583,30 +1691,33 @@ function createTypingGetEndpoint(slugs) {
|
|
|
1583
1691
|
path: "/support/typing",
|
|
1584
1692
|
method: "get",
|
|
1585
1693
|
handler: async (req) => {
|
|
1694
|
+
const idle = { typing: false, name: null };
|
|
1586
1695
|
try {
|
|
1587
1696
|
if (!req.user) {
|
|
1588
1697
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
1589
1698
|
}
|
|
1590
1699
|
const url = new URL(req.url);
|
|
1591
|
-
const
|
|
1592
|
-
if (!
|
|
1700
|
+
const key = normalizeTicketId(url.searchParams.get("ticketId"));
|
|
1701
|
+
if (!key) {
|
|
1593
1702
|
return Response.json({ error: "ticketId required" }, { status: 400 });
|
|
1594
1703
|
}
|
|
1595
|
-
cleanExpired(
|
|
1596
|
-
const state = typingState.get(
|
|
1704
|
+
cleanExpired(key);
|
|
1705
|
+
const state = typingState.get(key);
|
|
1706
|
+
if (!state) return Response.json(idle);
|
|
1707
|
+
if (!await mayAccessTicket(req, slugs, key)) return Response.json(idle);
|
|
1597
1708
|
if (req.user.collection === slugs.users) {
|
|
1598
1709
|
return Response.json({
|
|
1599
|
-
typing: !!state
|
|
1600
|
-
name: state
|
|
1710
|
+
typing: !!state.client,
|
|
1711
|
+
name: state.clientName || null
|
|
1601
1712
|
});
|
|
1602
1713
|
} else {
|
|
1603
1714
|
return Response.json({
|
|
1604
|
-
typing: !!state
|
|
1605
|
-
name: state
|
|
1715
|
+
typing: !!state.admin,
|
|
1716
|
+
name: state.adminName || null
|
|
1606
1717
|
});
|
|
1607
1718
|
}
|
|
1608
1719
|
} catch {
|
|
1609
|
-
return Response.json(
|
|
1720
|
+
return Response.json(idle);
|
|
1610
1721
|
}
|
|
1611
1722
|
}
|
|
1612
1723
|
};
|
|
@@ -1769,7 +1880,14 @@ function createSignatureGetEndpoint(slugs) {
|
|
|
1769
1880
|
const payload = req.payload;
|
|
1770
1881
|
requireAdmin(req, slugs);
|
|
1771
1882
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
1772
|
-
|
|
1883
|
+
// Scope to the staff auth collection: `payload-preferences` accepts a
|
|
1884
|
+
// write from ANY authenticated principal, and ids collide between auth
|
|
1885
|
+
// collections — a support-client with the same id would otherwise own
|
|
1886
|
+
// the `email-signature-<id>` row read back for the agent.
|
|
1887
|
+
where: {
|
|
1888
|
+
key: { equals: `${PREF_KEY2}-${req.user.id}` },
|
|
1889
|
+
"user.relationTo": { equals: slugs.users }
|
|
1890
|
+
},
|
|
1773
1891
|
limit: 1,
|
|
1774
1892
|
depth: 0,
|
|
1775
1893
|
overrideAccess: true
|
|
@@ -1796,7 +1914,7 @@ function createSignaturePostEndpoint(slugs) {
|
|
|
1796
1914
|
const { signature } = await req.json();
|
|
1797
1915
|
const key = `${PREF_KEY2}-${req.user.id}`;
|
|
1798
1916
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
1799
|
-
where: { key: { equals: key } },
|
|
1917
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
1800
1918
|
limit: 1,
|
|
1801
1919
|
depth: 0,
|
|
1802
1920
|
overrideAccess: true
|
|
@@ -2557,7 +2675,7 @@ function formatFr(date, withTime) {
|
|
|
2557
2675
|
});
|
|
2558
2676
|
}
|
|
2559
2677
|
function createSendReminderEndpoint(slugs, store) {
|
|
2560
|
-
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store);
|
|
2678
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1e3, 30, store, "send-reminder");
|
|
2561
2679
|
return {
|
|
2562
2680
|
path: "/support/send-reminder",
|
|
2563
2681
|
method: "post",
|
|
@@ -2565,7 +2683,7 @@ function createSendReminderEndpoint(slugs, store) {
|
|
|
2565
2683
|
try {
|
|
2566
2684
|
const payload = req.payload;
|
|
2567
2685
|
requireAdmin(req, slugs);
|
|
2568
|
-
if (await reminderLimiter.check(
|
|
2686
|
+
if (await reminderLimiter.check(principalRateKey(req.user), req)) {
|
|
2569
2687
|
return Response.json(
|
|
2570
2688
|
{ error: "Trop de relances. R\xE9essayez dans une heure." },
|
|
2571
2689
|
{ status: 429 }
|
|
@@ -2868,8 +2986,14 @@ function createPurgeLogsEndpoint(slugs) {
|
|
|
2868
2986
|
}
|
|
2869
2987
|
|
|
2870
2988
|
// src/endpoints/chatbot.ts
|
|
2871
|
-
|
|
2872
|
-
|
|
2989
|
+
var DEFAULT_CHATBOT_MAX_PER_HOUR = 200;
|
|
2990
|
+
function resolveMaxPerHour(explicit) {
|
|
2991
|
+
const fromEnv = Number(process.env.SUPPORT_CHATBOT_MAX_PER_HOUR);
|
|
2992
|
+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : DEFAULT_CHATBOT_MAX_PER_HOUR;
|
|
2993
|
+
}
|
|
2994
|
+
function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
2995
|
+
const chatbotLimiter = new RateLimiter(6e4, 10, store, "chatbot:ip");
|
|
2996
|
+
const globalLimiter = new RateLimiter(60 * 6e4, resolveMaxPerHour(), store, "chatbot:global");
|
|
2873
2997
|
return {
|
|
2874
2998
|
path: "/support/chatbot",
|
|
2875
2999
|
method: "post",
|
|
@@ -2889,6 +3013,15 @@ function createChatbotEndpoint(slugs, store) {
|
|
|
2889
3013
|
if (!question?.trim() || question.trim().length < 5) {
|
|
2890
3014
|
return Response.json({ error: "Question too short" }, { status: 400 });
|
|
2891
3015
|
}
|
|
3016
|
+
if (await globalLimiter.check("all", req)) {
|
|
3017
|
+
return Response.json({
|
|
3018
|
+
answer: null,
|
|
3019
|
+
confidence: 0,
|
|
3020
|
+
suggestion: "create_ticket",
|
|
3021
|
+
aiUnavailable: true,
|
|
3022
|
+
message: "L'assistant est momentan\xE9ment indisponible. Cr\xE9ez un ticket, un agent vous r\xE9pondra."
|
|
3023
|
+
});
|
|
3024
|
+
}
|
|
2892
3025
|
const payload = req.payload;
|
|
2893
3026
|
const articles = await dbFind(payload, slugs.knowledgeBase, {
|
|
2894
3027
|
where: { published: { equals: true } },
|
|
@@ -3001,8 +3134,8 @@ function createChatGetEndpoint(slugs) {
|
|
|
3001
3134
|
};
|
|
3002
3135
|
}
|
|
3003
3136
|
function createChatPostEndpoint(slugs, store) {
|
|
3004
|
-
const chatSessionLimiter = new RateLimiter(36e5, 5, store);
|
|
3005
|
-
const chatMessageLimiter = new RateLimiter(6e4, 15, store);
|
|
3137
|
+
const chatSessionLimiter = new RateLimiter(36e5, 5, store, "chat:session");
|
|
3138
|
+
const chatMessageLimiter = new RateLimiter(6e4, 15, store, "chat:message");
|
|
3006
3139
|
return {
|
|
3007
3140
|
path: "/support/chat",
|
|
3008
3141
|
method: "post",
|
|
@@ -3018,8 +3151,9 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3018
3151
|
}
|
|
3019
3152
|
const { action, session, message } = body;
|
|
3020
3153
|
const userId = String(req.user.id);
|
|
3154
|
+
const rateKey = principalRateKey(req.user);
|
|
3021
3155
|
if (action === "start") {
|
|
3022
|
-
if (await chatSessionLimiter.check(
|
|
3156
|
+
if (await chatSessionLimiter.check(rateKey, req)) {
|
|
3023
3157
|
return Response.json({ error: "Trop de sessions cr\xE9\xE9es. R\xE9essayez plus tard." }, { status: 429 });
|
|
3024
3158
|
}
|
|
3025
3159
|
const sessionId = `chat_${crypto3__default.default.randomBytes(16).toString("hex")}`;
|
|
@@ -3036,7 +3170,7 @@ function createChatPostEndpoint(slugs, store) {
|
|
|
3036
3170
|
return Response.json({ session: sessionId, messages: [systemMsg] });
|
|
3037
3171
|
}
|
|
3038
3172
|
if (action === "send" && session && message) {
|
|
3039
|
-
if (await chatMessageLimiter.check(
|
|
3173
|
+
if (await chatMessageLimiter.check(rateKey, req)) {
|
|
3040
3174
|
return Response.json({ error: "Trop de messages. Attendez un moment." }, { status: 429 });
|
|
3041
3175
|
}
|
|
3042
3176
|
const trimmedMessage = String(message).trim();
|
|
@@ -3276,7 +3410,7 @@ function createAdminChatGetEndpoint(slugs) {
|
|
|
3276
3410
|
};
|
|
3277
3411
|
}
|
|
3278
3412
|
function createAdminChatPostEndpoint(slugs, store) {
|
|
3279
|
-
const adminChatLimiter = new RateLimiter(6e4, 30, store);
|
|
3413
|
+
const adminChatLimiter = new RateLimiter(6e4, 30, store, "admin-chat");
|
|
3280
3414
|
return {
|
|
3281
3415
|
path: "/support/admin-chat",
|
|
3282
3416
|
method: "post",
|
|
@@ -3305,7 +3439,7 @@ function createAdminChatPostEndpoint(slugs, store) {
|
|
|
3305
3439
|
}
|
|
3306
3440
|
const clientId = typeof sessionMsg.docs[0].client === "object" ? sessionMsg.docs[0].client.id : sessionMsg.docs[0].client;
|
|
3307
3441
|
if (action === "send" && message) {
|
|
3308
|
-
if (await adminChatLimiter.check(
|
|
3442
|
+
if (await adminChatLimiter.check(principalRateKey(req.user), req)) {
|
|
3309
3443
|
return Response.json({ error: "Rate limit atteint." }, { status: 429 });
|
|
3310
3444
|
}
|
|
3311
3445
|
const trimmedMessage = String(message).trim();
|
|
@@ -4260,14 +4394,14 @@ Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`;
|
|
|
4260
4394
|
|
|
4261
4395
|
// src/endpoints/ticket-synthesis.ts
|
|
4262
4396
|
function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
4263
|
-
const limiter = new RateLimiter(6e4, 20, store);
|
|
4397
|
+
const limiter = new RateLimiter(6e4, 20, store, "ticket-synthesis");
|
|
4264
4398
|
return {
|
|
4265
4399
|
path: "/support/ticket-synthesis",
|
|
4266
4400
|
method: "post",
|
|
4267
4401
|
handler: async (req) => {
|
|
4268
4402
|
try {
|
|
4269
4403
|
requireAdmin(req, slugs);
|
|
4270
|
-
if (await limiter.check(
|
|
4404
|
+
if (await limiter.check(principalRateKey(req.user), req)) {
|
|
4271
4405
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
4272
4406
|
}
|
|
4273
4407
|
const payload = req.payload;
|
|
@@ -4311,15 +4445,17 @@ function createTicketSynthesisEndpoint(slugs, generator, store) {
|
|
|
4311
4445
|
}
|
|
4312
4446
|
|
|
4313
4447
|
// src/endpoints/email-stats.ts
|
|
4314
|
-
function createEmailStatsEndpoint(slugs) {
|
|
4448
|
+
function createEmailStatsEndpoint(slugs, store) {
|
|
4449
|
+
const statsLimiter = new RateLimiter(6e4, 20, store, "email-stats");
|
|
4315
4450
|
return {
|
|
4316
4451
|
path: "/support/email-stats",
|
|
4317
4452
|
method: "get",
|
|
4318
4453
|
handler: async (req) => {
|
|
4319
4454
|
try {
|
|
4320
4455
|
const payload = req.payload;
|
|
4321
|
-
|
|
4322
|
-
|
|
4456
|
+
requireAdmin(req, slugs);
|
|
4457
|
+
if (await statsLimiter.check(principalRateKey(req.user), req)) {
|
|
4458
|
+
return Response.json({ error: "Too many requests." }, { status: 429 });
|
|
4323
4459
|
}
|
|
4324
4460
|
const url = new URL(req.url);
|
|
4325
4461
|
const days = Math.min(Number(url.searchParams.get("days")) || 7, 365);
|
|
@@ -4386,6 +4522,8 @@ function createEmailStatsEndpoint(slugs) {
|
|
|
4386
4522
|
actions: Object.fromEntries(actionMap)
|
|
4387
4523
|
});
|
|
4388
4524
|
} catch (err) {
|
|
4525
|
+
const authResponse = handleAuthError(err);
|
|
4526
|
+
if (authResponse) return authResponse;
|
|
4389
4527
|
console.error("[email-stats] Error:", err);
|
|
4390
4528
|
return Response.json({ error: "Internal server error" }, { status: 500 });
|
|
4391
4529
|
}
|
|
@@ -4818,7 +4956,7 @@ function createPendingEmailsProcessEndpoint(slugs) {
|
|
|
4818
4956
|
|
|
4819
4957
|
// src/endpoints/resend-notification.ts
|
|
4820
4958
|
function createResendNotificationEndpoint(slugs, store) {
|
|
4821
|
-
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
4959
|
+
const resendLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "resend-notification");
|
|
4822
4960
|
return {
|
|
4823
4961
|
path: "/support/resend-notification",
|
|
4824
4962
|
method: "post",
|
|
@@ -4826,7 +4964,7 @@ function createResendNotificationEndpoint(slugs, store) {
|
|
|
4826
4964
|
try {
|
|
4827
4965
|
const payload = req.payload;
|
|
4828
4966
|
requireAdmin(req, slugs);
|
|
4829
|
-
if (await resendLimiter.check(
|
|
4967
|
+
if (await resendLimiter.check(principalRateKey(req.user), req)) {
|
|
4830
4968
|
return Response.json(
|
|
4831
4969
|
{ error: "Trop de renvois. R\xE9essayez dans une heure." },
|
|
4832
4970
|
{ status: 429 }
|
|
@@ -5027,10 +5165,48 @@ function createSeedKbEndpoint(slugs) {
|
|
|
5027
5165
|
}
|
|
5028
5166
|
};
|
|
5029
5167
|
}
|
|
5168
|
+
var TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1e3;
|
|
5169
|
+
function challengeSecret() {
|
|
5170
|
+
const secret = process.env.PAYLOAD_SECRET;
|
|
5171
|
+
if (!secret) {
|
|
5172
|
+
throw new Error(
|
|
5173
|
+
"[support][2fa] PAYLOAD_SECRET is not set \u2014 refusing to issue a 2FA challenge with an insecure fallback secret"
|
|
5174
|
+
);
|
|
5175
|
+
}
|
|
5176
|
+
return secret;
|
|
5177
|
+
}
|
|
5178
|
+
function normalizeEmail(email) {
|
|
5179
|
+
return String(email).trim().toLowerCase();
|
|
5180
|
+
}
|
|
5181
|
+
function sign(email, expiresAt) {
|
|
5182
|
+
return crypto3.createHmac("sha256", challengeSecret()).update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`).digest("hex");
|
|
5183
|
+
}
|
|
5184
|
+
function issueTwoFactorChallenge(email, now = Date.now()) {
|
|
5185
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS;
|
|
5186
|
+
return `${expiresAt}.${sign(email, expiresAt)}`;
|
|
5187
|
+
}
|
|
5188
|
+
function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
5189
|
+
if (typeof email !== "string" || !email || typeof token !== "string") return false;
|
|
5190
|
+
const separator = token.indexOf(".");
|
|
5191
|
+
if (separator <= 0) return false;
|
|
5192
|
+
const expiresAt = Number(token.slice(0, separator));
|
|
5193
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false;
|
|
5194
|
+
const received = token.slice(separator + 1);
|
|
5195
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false;
|
|
5196
|
+
let expected;
|
|
5197
|
+
try {
|
|
5198
|
+
expected = sign(email, expiresAt);
|
|
5199
|
+
} catch {
|
|
5200
|
+
return false;
|
|
5201
|
+
}
|
|
5202
|
+
const a = Buffer.from(expected, "hex");
|
|
5203
|
+
const b = Buffer.from(received.toLowerCase(), "hex");
|
|
5204
|
+
return a.length === b.length && crypto3.timingSafeEqual(a, b);
|
|
5205
|
+
}
|
|
5030
5206
|
|
|
5031
5207
|
// src/endpoints/login.ts
|
|
5032
5208
|
function createLoginEndpoint(slugs, store) {
|
|
5033
|
-
const loginLimiter = new RateLimiter(15 * 6e4, 10, store);
|
|
5209
|
+
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
5034
5210
|
return {
|
|
5035
5211
|
path: "/support/login",
|
|
5036
5212
|
method: "post",
|
|
@@ -5083,7 +5259,12 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5083
5259
|
} catch (err) {
|
|
5084
5260
|
const errorMessage = err instanceof Error ? err.message : "Erreur inconnue";
|
|
5085
5261
|
if (errorMessage.includes("2FA_REQUIRED")) {
|
|
5086
|
-
|
|
5262
|
+
let challenge;
|
|
5263
|
+
try {
|
|
5264
|
+
challenge = issueTwoFactorChallenge(email);
|
|
5265
|
+
} catch {
|
|
5266
|
+
}
|
|
5267
|
+
return Response.json({ requires2FA: true, ...challenge ? { challenge } : {} }, { status: 200 });
|
|
5087
5268
|
}
|
|
5088
5269
|
let errorReason = "Identifiants incorrects";
|
|
5089
5270
|
if (errorMessage.includes("locked") || errorMessage.includes("verrouill\xE9") || errorMessage.includes("Too many")) {
|
|
@@ -5115,8 +5296,8 @@ function hashCode(code) {
|
|
|
5115
5296
|
return crypto3.createHmac("sha256", secret).update(code).digest("hex");
|
|
5116
5297
|
}
|
|
5117
5298
|
function createAuth2faEndpoint(slugs, store) {
|
|
5118
|
-
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store);
|
|
5119
|
-
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store);
|
|
5299
|
+
const sendLimiter = new RateLimiter(60 * 60 * 1e3, 3, store, "2fa:send");
|
|
5300
|
+
const verifyLimiter = new RateLimiter(15 * 60 * 1e3, 5, store, "2fa:verify");
|
|
5120
5301
|
return {
|
|
5121
5302
|
path: "/support/2fa",
|
|
5122
5303
|
method: "post",
|
|
@@ -5129,12 +5310,18 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5129
5310
|
} catch {
|
|
5130
5311
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5131
5312
|
}
|
|
5132
|
-
const { action, email, code } = body;
|
|
5313
|
+
const { action, email, code, challenge } = body;
|
|
5133
5314
|
if (!action || !email) {
|
|
5134
5315
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5135
5316
|
}
|
|
5136
5317
|
const genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
|
|
5137
5318
|
if (action === "send") {
|
|
5319
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5320
|
+
return Response.json(
|
|
5321
|
+
{ error: "Authentification requise avant l'envoi d'un code." },
|
|
5322
|
+
{ status: 401 }
|
|
5323
|
+
);
|
|
5324
|
+
}
|
|
5138
5325
|
if (await sendLimiter.check(email, req)) {
|
|
5139
5326
|
return Response.json(genericSendResponse);
|
|
5140
5327
|
}
|
|
@@ -5228,6 +5415,33 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5228
5415
|
}
|
|
5229
5416
|
};
|
|
5230
5417
|
}
|
|
5418
|
+
var OAUTH_STATE_COOKIE = "support-oauth-state";
|
|
5419
|
+
var OAUTH_STATE_MAX_AGE = 600;
|
|
5420
|
+
var TWO_FA_WINDOW_MS = 5 * 60 * 1e3;
|
|
5421
|
+
function readCookie(header, name) {
|
|
5422
|
+
if (!header) return null;
|
|
5423
|
+
for (const part of header.split(";")) {
|
|
5424
|
+
const eq = part.indexOf("=");
|
|
5425
|
+
if (eq === -1) continue;
|
|
5426
|
+
if (part.slice(0, eq).trim() !== name) continue;
|
|
5427
|
+
try {
|
|
5428
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
5429
|
+
} catch {
|
|
5430
|
+
return part.slice(eq + 1).trim();
|
|
5431
|
+
}
|
|
5432
|
+
}
|
|
5433
|
+
return null;
|
|
5434
|
+
}
|
|
5435
|
+
function clearedStateCookie() {
|
|
5436
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5437
|
+
return `${OAUTH_STATE_COOKIE}=; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=0`;
|
|
5438
|
+
}
|
|
5439
|
+
function statesMatch(a, b) {
|
|
5440
|
+
if (!a || !b) return false;
|
|
5441
|
+
const left = crypto3__default.default.createHash("sha256").update(a).digest();
|
|
5442
|
+
const right = crypto3__default.default.createHash("sha256").update(b).digest();
|
|
5443
|
+
return crypto3__default.default.timingSafeEqual(left, right);
|
|
5444
|
+
}
|
|
5231
5445
|
function createOAuthGoogleEndpoint(slugs, options) {
|
|
5232
5446
|
return {
|
|
5233
5447
|
path: "/support/oauth/google",
|
|
@@ -5244,7 +5458,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5244
5458
|
}
|
|
5245
5459
|
try {
|
|
5246
5460
|
const body = await req.json();
|
|
5247
|
-
const { action, code, state: queryState
|
|
5461
|
+
const { action, code, state: queryState } = body;
|
|
5248
5462
|
if (action === "login") {
|
|
5249
5463
|
const oauthState = crypto3__default.default.randomBytes(32).toString("hex");
|
|
5250
5464
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5256,13 +5470,25 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5256
5470
|
state: oauthState,
|
|
5257
5471
|
prompt: "select_account"
|
|
5258
5472
|
});
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5473
|
+
const secure = process.env.NODE_ENV === "production";
|
|
5474
|
+
const loginHeaders = new Headers({ "Content-Type": "application/json" });
|
|
5475
|
+
loginHeaders.append(
|
|
5476
|
+
"Set-Cookie",
|
|
5477
|
+
`${OAUTH_STATE_COOKIE}=${oauthState}; HttpOnly; ${secure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${OAUTH_STATE_MAX_AGE}`
|
|
5478
|
+
);
|
|
5479
|
+
return new Response(
|
|
5480
|
+
JSON.stringify({
|
|
5481
|
+
url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
|
|
5482
|
+
// Kept for callers that echo it back in the redirect URL; the
|
|
5483
|
+
// server no longer trusts anything the caller returns.
|
|
5484
|
+
state: oauthState
|
|
5485
|
+
}),
|
|
5486
|
+
{ status: 200, headers: loginHeaders }
|
|
5487
|
+
);
|
|
5263
5488
|
}
|
|
5264
5489
|
if (code) {
|
|
5265
|
-
|
|
5490
|
+
const issuedState = readCookie(req.headers.get("cookie"), OAUTH_STATE_COOKIE);
|
|
5491
|
+
if (!statesMatch(issuedState, queryState)) {
|
|
5266
5492
|
return Response.json({ error: "state_mismatch" }, { status: 400 });
|
|
5267
5493
|
}
|
|
5268
5494
|
const redirectUri = `${baseUrl}/api/support/oauth/google`;
|
|
@@ -5344,6 +5570,35 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5344
5570
|
overrideAccess: true
|
|
5345
5571
|
});
|
|
5346
5572
|
}
|
|
5573
|
+
const twoFactorDoc = await dbFindByID(payload$1, slugs.supportClients, {
|
|
5574
|
+
id: clientDoc.id,
|
|
5575
|
+
depth: 0,
|
|
5576
|
+
overrideAccess: true,
|
|
5577
|
+
showHiddenFields: true
|
|
5578
|
+
});
|
|
5579
|
+
if (twoFactorDoc?.twoFactorEnabled) {
|
|
5580
|
+
const raw = twoFactorDoc.twoFactorVerifiedAt;
|
|
5581
|
+
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
5582
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS)) {
|
|
5583
|
+
let challenge;
|
|
5584
|
+
try {
|
|
5585
|
+
challenge = issueTwoFactorChallenge(clientDoc.email);
|
|
5586
|
+
} catch {
|
|
5587
|
+
}
|
|
5588
|
+
return new Response(JSON.stringify({ requires2FA: true, ...challenge ? { challenge } : {} }), {
|
|
5589
|
+
status: 200,
|
|
5590
|
+
headers: new Headers({
|
|
5591
|
+
"Content-Type": "application/json",
|
|
5592
|
+
"Set-Cookie": clearedStateCookie()
|
|
5593
|
+
})
|
|
5594
|
+
});
|
|
5595
|
+
}
|
|
5596
|
+
await dbUpdate(payload$1, slugs.supportClients, {
|
|
5597
|
+
id: clientDoc.id,
|
|
5598
|
+
data: { twoFactorVerifiedAt: null },
|
|
5599
|
+
overrideAccess: true
|
|
5600
|
+
});
|
|
5601
|
+
}
|
|
5347
5602
|
const secret = process.env.PAYLOAD_SECRET;
|
|
5348
5603
|
if (!secret) {
|
|
5349
5604
|
return Response.json({ error: "server_misconfigured" }, { status: 500 });
|
|
@@ -5387,6 +5642,7 @@ function createOAuthGoogleEndpoint(slugs, options) {
|
|
|
5387
5642
|
"Set-Cookie",
|
|
5388
5643
|
`payload-token=${token}; HttpOnly; ${cookieSecure ? "Secure; " : ""}SameSite=Lax; Path=/; Max-Age=${tokenExpiration}`
|
|
5389
5644
|
);
|
|
5645
|
+
headers.append("Set-Cookie", clearedStateCookie());
|
|
5390
5646
|
return new Response(JSON.stringify({ user: clientDoc, exp }), {
|
|
5391
5647
|
status: 200,
|
|
5392
5648
|
headers
|
|
@@ -5693,7 +5949,7 @@ ONLY JSON, nothing else.`
|
|
|
5693
5949
|
}
|
|
5694
5950
|
}
|
|
5695
5951
|
function createImportConversationEndpoint(slugs, store) {
|
|
5696
|
-
const importLimiter = new RateLimiter(36e5, 10, store);
|
|
5952
|
+
const importLimiter = new RateLimiter(36e5, 10, store, "import-conversation");
|
|
5697
5953
|
return {
|
|
5698
5954
|
path: "/support/import-conversation",
|
|
5699
5955
|
method: "post",
|
|
@@ -5836,6 +6092,137 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5836
6092
|
}
|
|
5837
6093
|
};
|
|
5838
6094
|
}
|
|
6095
|
+
function httpAllowed() {
|
|
6096
|
+
return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === "1";
|
|
6097
|
+
}
|
|
6098
|
+
var BLOCKED_HOST_SUFFIXES = [".local", ".localhost", ".internal", ".home.arpa"];
|
|
6099
|
+
function parseIPv4(host) {
|
|
6100
|
+
const parts = host.split(".");
|
|
6101
|
+
if (parts.length !== 4) return null;
|
|
6102
|
+
const octets = [];
|
|
6103
|
+
for (const part of parts) {
|
|
6104
|
+
if (!/^\d{1,3}$/.test(part)) return null;
|
|
6105
|
+
const n = Number(part);
|
|
6106
|
+
if (n > 255) return null;
|
|
6107
|
+
octets.push(n);
|
|
6108
|
+
}
|
|
6109
|
+
return octets;
|
|
6110
|
+
}
|
|
6111
|
+
function isPrivateIPv4(octets) {
|
|
6112
|
+
const [a, b] = octets;
|
|
6113
|
+
if (a === 0) return true;
|
|
6114
|
+
if (a === 10) return true;
|
|
6115
|
+
if (a === 127) return true;
|
|
6116
|
+
if (a === 169 && b === 254) return true;
|
|
6117
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
6118
|
+
if (a === 192 && b === 168) return true;
|
|
6119
|
+
if (a === 192 && b === 0) return true;
|
|
6120
|
+
if (a === 198 && (b === 18 || b === 19)) return true;
|
|
6121
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
6122
|
+
if (a >= 224) return true;
|
|
6123
|
+
return false;
|
|
6124
|
+
}
|
|
6125
|
+
function isPrivateIPv6(host) {
|
|
6126
|
+
const lower = host.toLowerCase();
|
|
6127
|
+
if (lower === "::" || lower === "::1") return true;
|
|
6128
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
|
|
6129
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
6130
|
+
if (lower.startsWith("ff")) return true;
|
|
6131
|
+
const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
6132
|
+
if (dotted) {
|
|
6133
|
+
const octets = parseIPv4(dotted[1]);
|
|
6134
|
+
return octets ? isPrivateIPv4(octets) : true;
|
|
6135
|
+
}
|
|
6136
|
+
const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
6137
|
+
if (hex) {
|
|
6138
|
+
const high = parseInt(hex[1], 16);
|
|
6139
|
+
const low = parseInt(hex[2], 16);
|
|
6140
|
+
return isPrivateIPv4([high >> 8, high & 255, low >> 8, low & 255]);
|
|
6141
|
+
}
|
|
6142
|
+
return false;
|
|
6143
|
+
}
|
|
6144
|
+
function normalizeHost(hostname) {
|
|
6145
|
+
const host = hostname.trim().toLowerCase();
|
|
6146
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
6147
|
+
}
|
|
6148
|
+
function isBlockedHost(hostname) {
|
|
6149
|
+
const host = normalizeHost(hostname);
|
|
6150
|
+
if (!host) return true;
|
|
6151
|
+
if (host === "localhost") return true;
|
|
6152
|
+
if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true;
|
|
6153
|
+
const v4 = parseIPv4(host);
|
|
6154
|
+
if (v4) return isPrivateIPv4(v4);
|
|
6155
|
+
if (host.includes(":")) return isPrivateIPv6(host);
|
|
6156
|
+
return false;
|
|
6157
|
+
}
|
|
6158
|
+
function validateWebhookUrl(raw) {
|
|
6159
|
+
if (typeof raw !== "string" || !raw.trim()) return { ok: false, reason: "invalid_url" };
|
|
6160
|
+
let url;
|
|
6161
|
+
try {
|
|
6162
|
+
url = new URL(raw.trim());
|
|
6163
|
+
} catch {
|
|
6164
|
+
return { ok: false, reason: "invalid_url" };
|
|
6165
|
+
}
|
|
6166
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && httpAllowed())) {
|
|
6167
|
+
return { ok: false, reason: "scheme_not_allowed" };
|
|
6168
|
+
}
|
|
6169
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6170
|
+
return { ok: true, url };
|
|
6171
|
+
}
|
|
6172
|
+
var WEBHOOK_URL_MESSAGES = {
|
|
6173
|
+
invalid_url: "URL invalide.",
|
|
6174
|
+
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6175
|
+
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6176
|
+
};
|
|
6177
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6178
|
+
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6179
|
+
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
6180
|
+
async function assertPublicHost(hostname) {
|
|
6181
|
+
const host = normalizeHost(hostname);
|
|
6182
|
+
if (isBlockedHost(host)) return false;
|
|
6183
|
+
if (parseIPv4(host) || host.includes(":")) return true;
|
|
6184
|
+
try {
|
|
6185
|
+
const addresses = await Promise.race([
|
|
6186
|
+
promises.lookup(host, { all: true }),
|
|
6187
|
+
new Promise(
|
|
6188
|
+
(resolve) => setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.()
|
|
6189
|
+
)
|
|
6190
|
+
]);
|
|
6191
|
+
if (addresses === LOOKUP_TIMED_OUT) return false;
|
|
6192
|
+
if (!Array.isArray(addresses) || addresses.length === 0) return false;
|
|
6193
|
+
return addresses.every((entry) => !isBlockedHost(entry.address));
|
|
6194
|
+
} catch (error) {
|
|
6195
|
+
const code = error?.code;
|
|
6196
|
+
return typeof code === "string" && NONEXISTENT_HOST_CODES.has(code);
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
var BlockedRequestError = class extends Error {
|
|
6200
|
+
constructor(reason) {
|
|
6201
|
+
super(`Blocked outbound request: ${reason}`);
|
|
6202
|
+
this.reason = reason;
|
|
6203
|
+
this.name = "BlockedRequestError";
|
|
6204
|
+
}
|
|
6205
|
+
reason;
|
|
6206
|
+
};
|
|
6207
|
+
var MAX_REDIRECTS = 3;
|
|
6208
|
+
async function safeFetch(rawUrl, init) {
|
|
6209
|
+
let current = rawUrl;
|
|
6210
|
+
let body = init.body;
|
|
6211
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
6212
|
+
const validation = validateWebhookUrl(current);
|
|
6213
|
+
if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || "invalid_url");
|
|
6214
|
+
if (!await assertPublicHost(validation.url.hostname)) throw new BlockedRequestError("private_host");
|
|
6215
|
+
const response = await fetch(current, { ...init, body, redirect: "manual" });
|
|
6216
|
+
if (response.status < 300 || response.status > 399) return response;
|
|
6217
|
+
const location = response.headers.get("location");
|
|
6218
|
+
if (!location) return response;
|
|
6219
|
+
current = new URL(location, current).toString();
|
|
6220
|
+
if (response.status === 303) body = void 0;
|
|
6221
|
+
}
|
|
6222
|
+
throw new BlockedRequestError("too_many_redirects");
|
|
6223
|
+
}
|
|
6224
|
+
|
|
6225
|
+
// src/utils/webhookDispatcher.ts
|
|
5839
6226
|
function dispatchWebhook(data, event, payload, slugs) {
|
|
5840
6227
|
const done = _dispatch(data, event, payload, slugs);
|
|
5841
6228
|
return done;
|
|
@@ -5872,7 +6259,7 @@ async function _sendToEndpoint(endpoint, body, payload, slugs) {
|
|
|
5872
6259
|
const signature = crypto3__default.default.createHmac("sha256", endpoint.secret).update(body).digest("hex");
|
|
5873
6260
|
headers["X-Webhook-Signature"] = signature;
|
|
5874
6261
|
}
|
|
5875
|
-
const response = await
|
|
6262
|
+
const response = await safeFetch(endpoint.url, {
|
|
5876
6263
|
method: "POST",
|
|
5877
6264
|
headers,
|
|
5878
6265
|
body,
|
|
@@ -6326,7 +6713,7 @@ function createUserPrefsGetEndpoint(slugs) {
|
|
|
6326
6713
|
requireAdmin(req, slugs);
|
|
6327
6714
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6328
6715
|
const prefs = await dbFind(payload, "payload-preferences", {
|
|
6329
|
-
where: { key: { equals: key } },
|
|
6716
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6330
6717
|
limit: 1,
|
|
6331
6718
|
depth: 0,
|
|
6332
6719
|
overrideAccess: true
|
|
@@ -6360,7 +6747,7 @@ function createUserPrefsPostEndpoint(slugs) {
|
|
|
6360
6747
|
const body = await req.json();
|
|
6361
6748
|
const key = `${PREF_KEY_PREFIX}-${req.user.id}`;
|
|
6362
6749
|
const existing = await dbFind(payload, "payload-preferences", {
|
|
6363
|
-
where: { key: { equals: key } },
|
|
6750
|
+
where: { key: { equals: key }, "user.relationTo": { equals: slugs.users } },
|
|
6364
6751
|
limit: 1,
|
|
6365
6752
|
depth: 0,
|
|
6366
6753
|
overrideAccess: true
|
|
@@ -6516,8 +6903,10 @@ function createTicketFeedbackEndpoint(slugs) {
|
|
|
6516
6903
|
// src/endpoints/transfer-ticket.ts
|
|
6517
6904
|
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6518
6905
|
var MAX_TRANSFERS_PER_DAY = 5;
|
|
6906
|
+
var MAX_TRANSFERS_PER_USER_PER_DAY = 15;
|
|
6519
6907
|
function createTransferTicketEndpoint(slugs, store) {
|
|
6520
|
-
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store);
|
|
6908
|
+
const transferLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_DAY, store, "transfer:ticket");
|
|
6909
|
+
const transferUserLimiter = new RateLimiter(24 * 60 * 60 * 1e3, MAX_TRANSFERS_PER_USER_PER_DAY, store, "transfer:user");
|
|
6521
6910
|
return {
|
|
6522
6911
|
path: "/support/tickets/:id/transfer",
|
|
6523
6912
|
method: "post",
|
|
@@ -6552,12 +6941,18 @@ function createTransferTicketEndpoint(slugs, store) {
|
|
|
6552
6941
|
if (!isAdmin && !isOwner) {
|
|
6553
6942
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6554
6943
|
}
|
|
6555
|
-
if (await transferLimiter.check(`${req.user
|
|
6944
|
+
if (await transferLimiter.check(`${principalRateKey(req.user)}:${ticketId}`, req)) {
|
|
6556
6945
|
return Response.json(
|
|
6557
6946
|
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
6558
6947
|
{ status: 429 }
|
|
6559
6948
|
);
|
|
6560
6949
|
}
|
|
6950
|
+
if (!isAdmin && await transferUserLimiter.check(principalRateKey(req.user), req)) {
|
|
6951
|
+
return Response.json(
|
|
6952
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_USER_PER_DAY} transferts par 24h)` },
|
|
6953
|
+
{ status: 429 }
|
|
6954
|
+
);
|
|
6955
|
+
}
|
|
6561
6956
|
try {
|
|
6562
6957
|
const since = new Date(Date.now() - 24 * 60 * 60 * 1e3).toISOString();
|
|
6563
6958
|
const existing = await dbCount(payload, slugs.emailLogs, {
|
|
@@ -6699,8 +7094,11 @@ function statusToLabel(status) {
|
|
|
6699
7094
|
}
|
|
6700
7095
|
var EMAIL_RE2 = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6701
7096
|
var MAX_COLLABORATORS_PER_TICKET = 20;
|
|
7097
|
+
var MAX_NEW_ACCOUNTS_PER_INVITER = 30;
|
|
7098
|
+
var NEW_ACCOUNT_WINDOW_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
6702
7099
|
function createInviteCollaboratorEndpoint(slugs, store) {
|
|
6703
|
-
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store);
|
|
7100
|
+
const inviteLimiter = new RateLimiter(60 * 60 * 1e3, 10, store, "invite-collaborator");
|
|
7101
|
+
const newAccountLimiter = new RateLimiter(NEW_ACCOUNT_WINDOW_MS, MAX_NEW_ACCOUNTS_PER_INVITER, store, "invite-collaborator:new-account");
|
|
6704
7102
|
return {
|
|
6705
7103
|
path: "/support/tickets/:id/invite",
|
|
6706
7104
|
method: "post",
|
|
@@ -6735,7 +7133,7 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6735
7133
|
if (!isAdmin && !isOwner) {
|
|
6736
7134
|
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
6737
7135
|
}
|
|
6738
|
-
if (await inviteLimiter.check(
|
|
7136
|
+
if (await inviteLimiter.check(principalRateKey(req.user), req)) {
|
|
6739
7137
|
return Response.json({ error: "Trop d'invitations. R\xE9essayez plus tard." }, { status: 429 });
|
|
6740
7138
|
}
|
|
6741
7139
|
const collabCount = await dbCount(payload, "ticket-collaborators", { where: { ticket: { equals: ticketId } }, overrideAccess: true }).catch(() => ({ totalDocs: 0 }));
|
|
@@ -6759,6 +7157,12 @@ function createInviteCollaboratorEndpoint(slugs, store) {
|
|
|
6759
7157
|
if (existing.docs.length > 0) {
|
|
6760
7158
|
inviteeId = existing.docs[0].id;
|
|
6761
7159
|
} else {
|
|
7160
|
+
if (!isAdmin && await newAccountLimiter.check(principalRateKey(req.user), req)) {
|
|
7161
|
+
return Response.json(
|
|
7162
|
+
{ error: "Trop de nouveaux comptes invit\xE9s. R\xE9essayez plus tard." },
|
|
7163
|
+
{ status: 429 }
|
|
7164
|
+
);
|
|
7165
|
+
}
|
|
6762
7166
|
const tempPassword = crypto3.randomBytes(16).toString("hex");
|
|
6763
7167
|
const created = await dbCreate(payload, slugs.supportClients, {
|
|
6764
7168
|
data: {
|
|
@@ -6930,7 +7334,7 @@ function createSupportEndpoints(slugs, options) {
|
|
|
6930
7334
|
}
|
|
6931
7335
|
if (!f || f.satisfaction !== false) endpoints.push(createSatisfactionEndpoint(slugs));
|
|
6932
7336
|
if (!f || f.emailTracking !== false) {
|
|
6933
|
-
endpoints.push(createEmailStatsEndpoint(slugs), createTrackOpenEndpoint(slugs));
|
|
7337
|
+
endpoints.push(createEmailStatsEndpoint(slugs, rateLimitStore), createTrackOpenEndpoint(slugs));
|
|
6934
7338
|
}
|
|
6935
7339
|
if (!f || f.pendingEmails !== false) endpoints.push(createPendingEmailsProcessEndpoint(slugs));
|
|
6936
7340
|
if (!f || f.scheduledReplies !== false) endpoints.push(createProcessScheduledEndpoint(slugs));
|
|
@@ -8415,7 +8819,7 @@ function createTicketsCollection(slugs, options) {
|
|
|
8415
8819
|
}
|
|
8416
8820
|
|
|
8417
8821
|
// src/utils/ticketAccess.ts
|
|
8418
|
-
async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
8822
|
+
async function resolveAccessibleTicketIds(payload, slugs, clientId, mode = "read") {
|
|
8419
8823
|
const ids = /* @__PURE__ */ new Set();
|
|
8420
8824
|
try {
|
|
8421
8825
|
const owned = await dbFind(payload, slugs.tickets, {
|
|
@@ -8436,6 +8840,7 @@ async function resolveAccessibleTicketIds(payload, slugs, clientId) {
|
|
|
8436
8840
|
});
|
|
8437
8841
|
for (const r of collab.docs) {
|
|
8438
8842
|
const row = r;
|
|
8843
|
+
if (mode === "write" && row.role !== "collaborator") continue;
|
|
8439
8844
|
const tid = typeof row.ticket === "object" ? row.ticket?.id : row.ticket;
|
|
8440
8845
|
if (tid !== void 0 && tid !== null) ids.add(tid);
|
|
8441
8846
|
}
|
|
@@ -8542,7 +8947,7 @@ function createRestrictClientTicketTarget(slugs) {
|
|
|
8542
8947
|
if (targetId === void 0 || targetId === null || targetId === "") {
|
|
8543
8948
|
throw new payload.APIError("Ticket cible requis.", 400);
|
|
8544
8949
|
}
|
|
8545
|
-
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id);
|
|
8950
|
+
const accessible = await resolveAccessibleTicketIds(req.payload, slugs, req.user.id, "write");
|
|
8546
8951
|
if (!accessible.some((id) => String(id) === String(targetId))) {
|
|
8547
8952
|
throw new payload.APIError("Ticket inaccessible.", 403);
|
|
8548
8953
|
}
|
|
@@ -9059,13 +9464,13 @@ function createSendInvitationOnCreate(slugs) {
|
|
|
9059
9464
|
return doc;
|
|
9060
9465
|
};
|
|
9061
9466
|
}
|
|
9062
|
-
var
|
|
9467
|
+
var TWO_FA_WINDOW_MS2 = 5 * 60 * 1e3;
|
|
9063
9468
|
function createEnforce2FA(slugs) {
|
|
9064
9469
|
return async ({ req, user }) => {
|
|
9065
9470
|
if (!user?.twoFactorEnabled) return user;
|
|
9066
9471
|
const raw = user.twoFactorVerifiedAt;
|
|
9067
9472
|
const verifiedAt = raw ? new Date(raw).getTime() : 0;
|
|
9068
|
-
if (!(verifiedAt > Date.now() -
|
|
9473
|
+
if (!(verifiedAt > Date.now() - TWO_FA_WINDOW_MS2)) {
|
|
9069
9474
|
throw new payload.APIError("2FA_REQUIRED", 401);
|
|
9070
9475
|
}
|
|
9071
9476
|
await req.payload.update({
|
|
@@ -9906,8 +10311,28 @@ function createKnowledgeBaseCollection(slugs) {
|
|
|
9906
10311
|
timestamps: true
|
|
9907
10312
|
};
|
|
9908
10313
|
}
|
|
9909
|
-
|
|
9910
|
-
|
|
10314
|
+
function createRestrictClientChatWrite(slugs) {
|
|
10315
|
+
return async ({ data, req }) => {
|
|
10316
|
+
if (req.user?.collection !== slugs.supportClients) return data;
|
|
10317
|
+
data.client = req.user.id;
|
|
10318
|
+
data.senderType = "client";
|
|
10319
|
+
delete data.agent;
|
|
10320
|
+
const session = typeof data.session === "string" ? data.session : null;
|
|
10321
|
+
if (!session) return data;
|
|
10322
|
+
const existing = await dbFind(req.payload, slugs.chatMessages, {
|
|
10323
|
+
where: { session: { equals: session } },
|
|
10324
|
+
limit: 1,
|
|
10325
|
+
depth: 0,
|
|
10326
|
+
overrideAccess: true
|
|
10327
|
+
});
|
|
10328
|
+
const owner = existing.docs[0]?.client;
|
|
10329
|
+
const ownerId = owner && typeof owner === "object" ? owner.id : owner;
|
|
10330
|
+
if (ownerId !== void 0 && ownerId !== null && String(ownerId) !== String(req.user.id)) {
|
|
10331
|
+
throw new payload.APIError("Session inaccessible.", 403);
|
|
10332
|
+
}
|
|
10333
|
+
return data;
|
|
10334
|
+
};
|
|
10335
|
+
}
|
|
9911
10336
|
function createChatMessagesCollection(slugs) {
|
|
9912
10337
|
return {
|
|
9913
10338
|
slug: slugs.chatMessages,
|
|
@@ -9929,7 +10354,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9929
10354
|
}
|
|
9930
10355
|
return false;
|
|
9931
10356
|
},
|
|
9932
|
-
|
|
10357
|
+
// Staff and support-clients only — NOT "any authenticated principal":
|
|
10358
|
+
// a user of any other auth collection of the host app satisfied `!!req.user`.
|
|
10359
|
+
create: ({ req }) => req.user?.collection === slugs.users || req.user?.collection === slugs.supportClients,
|
|
9933
10360
|
update: ({ req }) => req.user?.collection === slugs.users,
|
|
9934
10361
|
delete: ({ req }) => req.user?.collection === slugs.users
|
|
9935
10362
|
},
|
|
@@ -9998,6 +10425,9 @@ function createChatMessagesCollection(slugs) {
|
|
|
9998
10425
|
}
|
|
9999
10426
|
}
|
|
10000
10427
|
],
|
|
10428
|
+
hooks: {
|
|
10429
|
+
beforeChange: [createRestrictClientChatWrite(slugs)]
|
|
10430
|
+
},
|
|
10001
10431
|
timestamps: true
|
|
10002
10432
|
};
|
|
10003
10433
|
}
|
|
@@ -10275,8 +10705,19 @@ function createAuthLogsCollection(slugs) {
|
|
|
10275
10705
|
timestamps: true
|
|
10276
10706
|
};
|
|
10277
10707
|
}
|
|
10278
|
-
|
|
10279
|
-
|
|
10708
|
+
function createValidateWebhookUrl() {
|
|
10709
|
+
return ({ data, operation, originalDoc }) => {
|
|
10710
|
+
const incoming = data?.url;
|
|
10711
|
+
if (incoming === void 0 || incoming === null) return data;
|
|
10712
|
+
const previous = originalDoc?.url;
|
|
10713
|
+
if (operation === "update" && incoming === previous) return data;
|
|
10714
|
+
const result = validateWebhookUrl(incoming);
|
|
10715
|
+
if (!result.ok) {
|
|
10716
|
+
throw new payload.APIError(WEBHOOK_URL_MESSAGES[result.reason || "invalid_url"], 400);
|
|
10717
|
+
}
|
|
10718
|
+
return data;
|
|
10719
|
+
};
|
|
10720
|
+
}
|
|
10280
10721
|
function createWebhookEndpointsCollection(slugs) {
|
|
10281
10722
|
return {
|
|
10282
10723
|
slug: slugs.webhookEndpoints,
|
|
@@ -10310,8 +10751,11 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10310
10751
|
type: "text",
|
|
10311
10752
|
required: true,
|
|
10312
10753
|
label: "URL",
|
|
10754
|
+
// The SSRF check lives in the collection `beforeValidate` above, NOT in a
|
|
10755
|
+
// field `validate`: the latter re-runs on the merged document and would
|
|
10756
|
+
// freeze every pre-existing row on any unrelated edit.
|
|
10313
10757
|
admin: {
|
|
10314
|
-
description: "URL du webhook \xE0 appeler (POST)"
|
|
10758
|
+
description: "URL https:// du webhook \xE0 appeler (POST). Les adresses priv\xE9es et loopback sont refus\xE9es."
|
|
10315
10759
|
}
|
|
10316
10760
|
},
|
|
10317
10761
|
{
|
|
@@ -10365,6 +10809,9 @@ function createWebhookEndpointsCollection(slugs) {
|
|
|
10365
10809
|
}
|
|
10366
10810
|
}
|
|
10367
10811
|
],
|
|
10812
|
+
hooks: {
|
|
10813
|
+
beforeValidate: [createValidateWebhookUrl()]
|
|
10814
|
+
},
|
|
10368
10815
|
timestamps: true
|
|
10369
10816
|
};
|
|
10370
10817
|
}
|
|
@@ -10816,11 +11263,17 @@ function createClientSummariesCollection(slugs) {
|
|
|
10816
11263
|
admin: { readOnly: true }
|
|
10817
11264
|
}
|
|
10818
11265
|
],
|
|
11266
|
+
// Staff-only, on the SAME source of truth as every other collection and as
|
|
11267
|
+
// `requireAdmin`: `slugs.users`. The literal `'users'` this used to compare
|
|
11268
|
+
// against is the DEFAULT slug, not the configured one — on a host app whose
|
|
11269
|
+
// staff collection is renamed (`collectionSlugs.users: 'admins'`) it named
|
|
11270
|
+
// the front-office collection instead, opening read/create/update/delete on
|
|
11271
|
+
// AI-generated client intelligence to it while locking the real agents out.
|
|
10819
11272
|
access: {
|
|
10820
|
-
create: ({ req }) => req.user?.collection ===
|
|
10821
|
-
read: ({ req }) => req.user?.collection ===
|
|
10822
|
-
update: ({ req }) => req.user?.collection ===
|
|
10823
|
-
delete: ({ req }) => req.user?.collection ===
|
|
11273
|
+
create: ({ req }) => req.user?.collection === slugs.users,
|
|
11274
|
+
read: ({ req }) => req.user?.collection === slugs.users,
|
|
11275
|
+
update: ({ req }) => req.user?.collection === slugs.users,
|
|
11276
|
+
delete: ({ req }) => req.user?.collection === slugs.users
|
|
10824
11277
|
},
|
|
10825
11278
|
timestamps: true
|
|
10826
11279
|
};
|
|
@@ -11284,6 +11737,17 @@ function supportPlugin(config) {
|
|
|
11284
11737
|
});
|
|
11285
11738
|
return {
|
|
11286
11739
|
...incomingConfig,
|
|
11740
|
+
// Publish the resolved staff collection so the server-side readers share
|
|
11741
|
+
// ONE source of truth with the writers. `requireAdmin` compares against
|
|
11742
|
+
// `slugs.users`; the `payload-preferences` reads used to scope themselves
|
|
11743
|
+
// on `config.admin.user`, which Payload silently defaults to the first
|
|
11744
|
+
// auth collection of the host app — a different collection on any app
|
|
11745
|
+
// that declares `collectionSlugs.users`, and the settings-poisoning hole
|
|
11746
|
+
// reopened right there.
|
|
11747
|
+
custom: {
|
|
11748
|
+
...incomingConfig.custom,
|
|
11749
|
+
[SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users
|
|
11750
|
+
},
|
|
11287
11751
|
collections: config?.skipCollections ? existingCollections : [...existingCollections, ...supportCollections],
|
|
11288
11752
|
endpoints: config?.skipEndpoints ? existingEndpoints : [...existingEndpoints, ...supportEndpoints],
|
|
11289
11753
|
admin: {
|