@siteping/server 0.1.1 → 0.2.1
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 +1 -1
- package/dist/deep-link.d.cts +10 -0
- package/dist/deep-link.d.ts +10 -0
- package/dist/errors.d.cts +46 -16
- package/dist/errors.d.ts +46 -16
- package/dist/i18n.d.cts +14 -10
- package/dist/i18n.d.ts +14 -10
- package/dist/index.cjs +536 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +156 -71
- package/dist/index.d.ts +156 -71
- package/dist/index.js +536 -259
- package/dist/index.js.map +1 -1
- package/dist/logger.d.cts +30 -0
- package/dist/logger.d.ts +30 -0
- package/dist/schema.d.cts +31 -0
- package/dist/schema.d.ts +31 -0
- package/dist/screenshot-storage.d.cts +14 -17
- package/dist/screenshot-storage.d.ts +14 -17
- package/dist/siteping-core.d.cts +8 -5
- package/dist/siteping-core.d.ts +8 -5
- package/dist/store-helpers.d.cts +13 -9
- package/dist/store-helpers.d.ts +13 -9
- package/dist/text.d.cts +28 -0
- package/dist/text.d.ts +28 -0
- package/dist/types.d.cts +114 -37
- package/dist/types.d.ts +114 -37
- package/dist/wire.d.cts +6 -1
- package/dist/wire.d.ts +6 -1
- package/openapi.json +1532 -0
- package/package.json +9 -7
package/dist/index.js
CHANGED
|
@@ -9,8 +9,23 @@ function hasOwn(value, key) {
|
|
|
9
9
|
return isRecord(value) && key in value;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// ../core/src/errors.ts
|
|
13
|
+
var SitepingConfigError = class extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "SitepingConfigError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function isSitepingConfigError(error) {
|
|
22
|
+
return hasOwn(error, "name") && error.name === "SitepingConfigError" && hasOwn(error, "code") && typeof error.code === "string" && error.code.startsWith("CONFIG_");
|
|
23
|
+
}
|
|
24
|
+
|
|
12
25
|
// ../core/src/types.ts
|
|
13
26
|
var IDENTITY_FIELD_MAX_LENGTH = 200;
|
|
27
|
+
var PROJECT_NAME_MAX_LENGTH = 191;
|
|
28
|
+
var CLIENT_ID_MAX_LENGTH = 191;
|
|
14
29
|
var FEEDBACK_TYPES = ["question", "change", "bug", "other"];
|
|
15
30
|
var FEEDBACK_STATUSES = ["open", "in_progress", "resolved", "wont_fix"];
|
|
16
31
|
var CLOSED_FEEDBACK_STATUSES = ["resolved", "wont_fix"];
|
|
@@ -96,6 +111,7 @@ function flattenAnnotation(ann) {
|
|
|
96
111
|
var CONSOLE_DIAGNOSTIC_LEVELS = ["log", "info", "warn", "error"];
|
|
97
112
|
var ANCHOR_ELEMENT_TAG_MAX = 191;
|
|
98
113
|
var ANCHOR_ELEMENT_ID_MAX = 191;
|
|
114
|
+
var SITEPING_PROTOCOL_VERSION = 1;
|
|
99
115
|
|
|
100
116
|
// src/constants.ts
|
|
101
117
|
var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
|
|
@@ -154,7 +170,8 @@ function createApiKeyGate({
|
|
|
154
170
|
redactUnauthenticatedEmails = true
|
|
155
171
|
}) {
|
|
156
172
|
if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
|
|
157
|
-
throw new
|
|
173
|
+
throw new SitepingConfigError(
|
|
174
|
+
"CONFIG_API_KEY_REQUIRED",
|
|
158
175
|
"[siteping] createSitepingHandler: apiKey is required in production. Set `apiKey` to enable destructive endpoints, pass `access` to plug in your own authentication, or pass `requireAuthForDestructive: false` if SitePing sits behind your own auth middleware."
|
|
159
176
|
);
|
|
160
177
|
}
|
|
@@ -165,15 +182,14 @@ function createApiKeyGate({
|
|
|
165
182
|
return header !== null && safeCompare(header, `Bearer ${apiKey}`);
|
|
166
183
|
};
|
|
167
184
|
const authenticate = async (request, method) => {
|
|
168
|
-
const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
|
|
169
185
|
if (!apiKey) {
|
|
170
186
|
if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
|
|
171
187
|
return { ok: false, status: 401, error: ERROR_MESSAGES.apiKeyRequiredForDestructive };
|
|
172
188
|
}
|
|
173
|
-
return { ok: true, principal: null
|
|
189
|
+
return { ok: true, principal: null };
|
|
174
190
|
}
|
|
175
191
|
if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
|
|
176
|
-
return { ok: true, principal: null
|
|
192
|
+
return { ok: true, principal: null };
|
|
177
193
|
}
|
|
178
194
|
return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
|
|
179
195
|
};
|
|
@@ -190,6 +206,10 @@ function createApiKeyGate({
|
|
|
190
206
|
async authorize() {
|
|
191
207
|
return true;
|
|
192
208
|
},
|
|
209
|
+
// One key covers every project, so the project changes nothing here.
|
|
210
|
+
async canReadAuthorEmail({ request }) {
|
|
211
|
+
return !redactUnauthenticatedEmails || isBearerAuthenticated(request);
|
|
212
|
+
},
|
|
193
213
|
// The key proves the caller is the project side; a public POST does not.
|
|
194
214
|
async canCommentAsTeam({ request }) {
|
|
195
215
|
return isBearerAuthenticated(request);
|
|
@@ -206,7 +226,7 @@ function createAccessGate(access) {
|
|
|
206
226
|
if (!principal) {
|
|
207
227
|
return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
|
|
208
228
|
}
|
|
209
|
-
return { ok: true, principal
|
|
229
|
+
return { ok: true, principal };
|
|
210
230
|
},
|
|
211
231
|
// A dry run reuses the response's request, so `authenticate` is never asked
|
|
212
232
|
// about another method: `authorize` decides, by action.
|
|
@@ -216,10 +236,15 @@ function createAccessGate(access) {
|
|
|
216
236
|
async authorize(context) {
|
|
217
237
|
return access.authorize ? access.authorize(context) : true;
|
|
218
238
|
},
|
|
239
|
+
// Emails are personal data: only a `true` answer reveals them — no
|
|
240
|
+
// callback, or `undefined` from a principal missing the flag, hides them.
|
|
241
|
+
async canReadAuthorEmail({ request, principal }, projectName) {
|
|
242
|
+
return await access.canReadAuthorEmail?.(principal, { request, projectName }) === true;
|
|
243
|
+
},
|
|
219
244
|
// Speaking as the team is an impersonation privilege: without the host's
|
|
220
245
|
// word for it (its own callback, or the email access it grants), refuse.
|
|
221
|
-
async canCommentAsTeam({ principal }, canReadAuthorEmail) {
|
|
222
|
-
return access.canCommentAsTeam ? await access.canCommentAsTeam(principal) === true : canReadAuthorEmail;
|
|
246
|
+
async canCommentAsTeam({ request, principal }, projectName, canReadAuthorEmail) {
|
|
247
|
+
return access.canCommentAsTeam ? await access.canCommentAsTeam(principal, { request, projectName }) === true : canReadAuthorEmail;
|
|
223
248
|
}
|
|
224
249
|
};
|
|
225
250
|
}
|
|
@@ -267,6 +292,389 @@ function preflightResponse(request, allowedOrigins) {
|
|
|
267
292
|
return new Response(null, { status: 204, headers });
|
|
268
293
|
}
|
|
269
294
|
|
|
295
|
+
// src/webhooks.ts
|
|
296
|
+
var WEBHOOK_EVENTS = [
|
|
297
|
+
"feedback.created",
|
|
298
|
+
"feedback.status_changed",
|
|
299
|
+
"feedback.deleted",
|
|
300
|
+
"comment.created"
|
|
301
|
+
];
|
|
302
|
+
var DEFAULT_EVENTS = ["feedback.created"];
|
|
303
|
+
var EVENT_HEADER = "X-Siteping-Event";
|
|
304
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
305
|
+
var TIMER_MAX_DELAY_MS = 2147483647;
|
|
306
|
+
function timeoutProblem(timeoutMs) {
|
|
307
|
+
if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
|
|
311
|
+
}
|
|
312
|
+
function eventsProblem(events) {
|
|
313
|
+
if (events === void 0) return null;
|
|
314
|
+
if (!Array.isArray(events) || events.length === 0) return "events must list at least one event";
|
|
315
|
+
const unknown = events.find((event) => !WEBHOOK_EVENTS.includes(event));
|
|
316
|
+
if (unknown !== void 0)
|
|
317
|
+
return `unknown event ${JSON.stringify(unknown)} in events (one of ${WEBHOOK_EVENTS.join(", ")})`;
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
function checkWebhookConfigs(configs) {
|
|
321
|
+
for (const config of configs) {
|
|
322
|
+
const problem = timeoutProblem(config.timeoutMs) ?? eventsProblem(config.events);
|
|
323
|
+
if (problem) {
|
|
324
|
+
throw new SitepingConfigError(
|
|
325
|
+
"CONFIG_WEBHOOK_TIMEOUT_INVALID",
|
|
326
|
+
`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
function subscribesTo(config, event) {
|
|
332
|
+
return (config.events ?? DEFAULT_EVENTS).includes(event);
|
|
333
|
+
}
|
|
334
|
+
var DISCORD_COLORS = {
|
|
335
|
+
bug: 15680580,
|
|
336
|
+
question: 3900150,
|
|
337
|
+
change: 16096779,
|
|
338
|
+
other: 7041664
|
|
339
|
+
};
|
|
340
|
+
var DEFAULT_DISCORD_COLOR = 7041664;
|
|
341
|
+
function deliverySubject(delivery) {
|
|
342
|
+
switch (delivery.event) {
|
|
343
|
+
case "feedback.deleted":
|
|
344
|
+
return delivery.target.kind === "single" ? { feedbackId: delivery.target.id, projectName: delivery.target.projectName } : { feedbackId: "", projectName: delivery.target.projectName };
|
|
345
|
+
case "comment.created":
|
|
346
|
+
return { feedbackId: delivery.comment.feedbackId, projectName: delivery.comment.projectName };
|
|
347
|
+
default:
|
|
348
|
+
return { feedbackId: delivery.feedback.id, projectName: delivery.feedback.projectName };
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function truncate(text, max = 300) {
|
|
352
|
+
if (text.length <= max) return text;
|
|
353
|
+
return `${text.slice(0, max - 1)}\u2026`;
|
|
354
|
+
}
|
|
355
|
+
function fitEscaped(units, max) {
|
|
356
|
+
const escaped = units.join("");
|
|
357
|
+
if (escaped.length <= max) return escaped;
|
|
358
|
+
let out = "";
|
|
359
|
+
for (const unit of units) {
|
|
360
|
+
if (out.length + unit.length > max - 1) break;
|
|
361
|
+
out += unit;
|
|
362
|
+
}
|
|
363
|
+
return `${out}\u2026`;
|
|
364
|
+
}
|
|
365
|
+
var SLACK_HEADER_MAX = 150;
|
|
366
|
+
var SLACK_TEXT_MAX = 3e3;
|
|
367
|
+
function escapeSlackText(text) {
|
|
368
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
369
|
+
}
|
|
370
|
+
var slackField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
|
|
371
|
+
var SLACK_FROM_HALF = Math.floor((SLACK_TEXT_MAX - 11) / 2);
|
|
372
|
+
var slackProject = (projectName) => `*Project:* ${slackField(projectName, SLACK_TEXT_MAX - 11)}`;
|
|
373
|
+
var slackUrl = (url) => `*URL:* ${slackField(url, SLACK_TEXT_MAX - 7)}`;
|
|
374
|
+
function slackMessage(headline, preview, context) {
|
|
375
|
+
return {
|
|
376
|
+
text: `${escapeSlackText(headline)}: ${preview}`,
|
|
377
|
+
blocks: [
|
|
378
|
+
{
|
|
379
|
+
type: "header",
|
|
380
|
+
text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
type: "section",
|
|
384
|
+
text: { type: "mrkdwn", text: preview }
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
type: "context",
|
|
388
|
+
elements: context.map((text) => ({ type: "mrkdwn", text }))
|
|
389
|
+
}
|
|
390
|
+
]
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function slackFrom(authorName, authorEmail) {
|
|
394
|
+
const email = authorEmail ? ` (${slackField(authorEmail, SLACK_FROM_HALF)})` : "";
|
|
395
|
+
return `*From:* ${slackField(authorName, SLACK_FROM_HALF)}${email}`;
|
|
396
|
+
}
|
|
397
|
+
var STATUS_VERBS = {
|
|
398
|
+
open: "reopened",
|
|
399
|
+
in_progress: "marked in progress",
|
|
400
|
+
resolved: "resolved",
|
|
401
|
+
wont_fix: "marked won't fix"
|
|
402
|
+
};
|
|
403
|
+
function buildSlackPayload(feedback) {
|
|
404
|
+
return slackMessage(
|
|
405
|
+
`New ${feedback.type} feedback from ${feedback.authorName}`,
|
|
406
|
+
escapeSlackText(truncate(feedback.message)),
|
|
407
|
+
[
|
|
408
|
+
slackProject(feedback.projectName),
|
|
409
|
+
`*Type:* ${feedback.type}`,
|
|
410
|
+
slackUrl(feedback.url),
|
|
411
|
+
`*From:* ${slackField(feedback.authorName, SLACK_FROM_HALF)} (${slackField(feedback.authorEmail, SLACK_FROM_HALF)})`
|
|
412
|
+
]
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
function buildSlackStatusPayload(feedback) {
|
|
416
|
+
return slackMessage(
|
|
417
|
+
`Feedback ${STATUS_VERBS[feedback.status]}: ${feedback.type} from ${feedback.authorName}`,
|
|
418
|
+
escapeSlackText(truncate(feedback.message)),
|
|
419
|
+
[
|
|
420
|
+
slackProject(feedback.projectName),
|
|
421
|
+
`*Status:* ${feedback.status}`,
|
|
422
|
+
slackUrl(feedback.url),
|
|
423
|
+
slackFrom(feedback.authorName, feedback.authorEmail)
|
|
424
|
+
]
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
function buildSlackDeletionPayload(target) {
|
|
428
|
+
if (target.kind === "project") {
|
|
429
|
+
return slackMessage(
|
|
430
|
+
"All feedback deleted",
|
|
431
|
+
`Every feedback of ${slackField(target.projectName, SLACK_TEXT_MAX - 30)} was deleted.`,
|
|
432
|
+
[slackProject(target.projectName)]
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return slackMessage("Feedback deleted", `Feedback ${slackField(target.id, SLACK_TEXT_MAX - 30)} was deleted.`, [
|
|
436
|
+
slackProject(target.projectName),
|
|
437
|
+
`*ID:* ${slackField(target.id, SLACK_TEXT_MAX - 6)}`
|
|
438
|
+
]);
|
|
439
|
+
}
|
|
440
|
+
function buildSlackCommentPayload(comment) {
|
|
441
|
+
return slackMessage(
|
|
442
|
+
`New ${comment.authorRole} reply from ${comment.authorName}`,
|
|
443
|
+
escapeSlackText(truncate(comment.body)),
|
|
444
|
+
[
|
|
445
|
+
slackProject(comment.projectName),
|
|
446
|
+
`*Feedback:* ${slackField(comment.feedbackId, SLACK_TEXT_MAX - 12)}`,
|
|
447
|
+
slackFrom(comment.authorName, comment.authorEmail)
|
|
448
|
+
]
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
var DISCORD_CONTENT_MAX = 2e3;
|
|
452
|
+
var DISCORD_TITLE_MAX = 256;
|
|
453
|
+
var DISCORD_FIELD_VALUE_MAX = 1024;
|
|
454
|
+
var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
|
|
455
|
+
var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
|
|
456
|
+
var DISCORD_LINK_TRAILER = `.,:;"')]`;
|
|
457
|
+
var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
|
|
458
|
+
var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
459
|
+
var count = (text, char) => text.split(char).length - 1;
|
|
460
|
+
function discordUrlUnits(url) {
|
|
461
|
+
const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
|
|
462
|
+
let end = url.length;
|
|
463
|
+
while (end > 0) {
|
|
464
|
+
const last = url.charAt(end - 1);
|
|
465
|
+
if (!DISCORD_LINK_TRAILER.includes(last)) break;
|
|
466
|
+
if (last === ")" || last === "]") {
|
|
467
|
+
if (open[last] >= 0) break;
|
|
468
|
+
open[last] += 1;
|
|
469
|
+
}
|
|
470
|
+
end -= 1;
|
|
471
|
+
}
|
|
472
|
+
return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
|
|
473
|
+
}
|
|
474
|
+
function escapeDiscordText(text, max) {
|
|
475
|
+
const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
|
|
476
|
+
return fitEscaped(units, max);
|
|
477
|
+
}
|
|
478
|
+
function buildDiscordPayload(feedback) {
|
|
479
|
+
const contentLead = `New **${feedback.type}** feedback from **`;
|
|
480
|
+
const titleLead = `${feedback.type} \u2014 `;
|
|
481
|
+
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
482
|
+
return {
|
|
483
|
+
content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
484
|
+
embeds: [
|
|
485
|
+
{
|
|
486
|
+
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
487
|
+
description: escapeDiscordText(feedback.message, 300),
|
|
488
|
+
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
489
|
+
fields: [
|
|
490
|
+
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
|
|
491
|
+
{
|
|
492
|
+
name: "Author",
|
|
493
|
+
value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
|
|
494
|
+
inline: true
|
|
495
|
+
},
|
|
496
|
+
{ name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
|
|
497
|
+
],
|
|
498
|
+
timestamp: new Date(feedback.createdAt).toISOString()
|
|
499
|
+
}
|
|
500
|
+
],
|
|
501
|
+
allowed_mentions: { parse: [] }
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
var COMMENT_DISCORD_COLOR = 9133302;
|
|
505
|
+
function discordMessage(content, embed) {
|
|
506
|
+
return { content, embeds: [embed], allowed_mentions: { parse: [] } };
|
|
507
|
+
}
|
|
508
|
+
function buildDiscordStatusPayload(feedback) {
|
|
509
|
+
const contentLead = `Feedback **${STATUS_VERBS[feedback.status]}**: ${feedback.type} from **`;
|
|
510
|
+
const titleLead = `${feedback.type} \u2014 `;
|
|
511
|
+
return discordMessage(
|
|
512
|
+
`${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
513
|
+
{
|
|
514
|
+
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
515
|
+
description: escapeDiscordText(feedback.message, 300),
|
|
516
|
+
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
517
|
+
fields: [
|
|
518
|
+
{ name: "Status", value: feedback.status, inline: true },
|
|
519
|
+
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false }
|
|
520
|
+
],
|
|
521
|
+
timestamp: new Date(feedback.updatedAt).toISOString()
|
|
522
|
+
}
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
function buildDiscordDeletionPayload(target) {
|
|
526
|
+
const project = (max) => escapeDiscordText(target.projectName, max);
|
|
527
|
+
const contentLead = target.kind === "project" ? "All feedback deleted from **" : "Feedback deleted from **";
|
|
528
|
+
const fields = [{ name: "Project", value: project(DISCORD_FIELD_VALUE_MAX), inline: true }];
|
|
529
|
+
if (target.kind === "single") {
|
|
530
|
+
fields.push({ name: "ID", value: escapeDiscordText(target.id, DISCORD_FIELD_VALUE_MAX), inline: true });
|
|
531
|
+
}
|
|
532
|
+
return discordMessage(`${contentLead}${project(DISCORD_CONTENT_MAX - contentLead.length - 2)}**`, {
|
|
533
|
+
title: target.kind === "project" ? "All feedback deleted" : "Feedback deleted",
|
|
534
|
+
description: target.kind === "project" ? `Every feedback of ${project(300)} was deleted.` : `Feedback ${escapeDiscordText(target.id, 300)} was deleted.`,
|
|
535
|
+
color: DEFAULT_DISCORD_COLOR,
|
|
536
|
+
fields,
|
|
537
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
function buildDiscordCommentPayload(comment) {
|
|
541
|
+
const contentLead = `New **${comment.authorRole}** reply from **`;
|
|
542
|
+
const titleLead = "Reply \u2014 ";
|
|
543
|
+
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
544
|
+
const author = comment.authorEmail ? `${escapeDiscordText(comment.authorName, authorHalf)} (${escapeDiscordText(comment.authorEmail, authorHalf)})` : escapeDiscordText(comment.authorName, DISCORD_FIELD_VALUE_MAX);
|
|
545
|
+
return discordMessage(
|
|
546
|
+
`${contentLead}${escapeDiscordText(comment.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
547
|
+
{
|
|
548
|
+
title: `${titleLead}${escapeDiscordText(comment.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
549
|
+
description: escapeDiscordText(comment.body, 300),
|
|
550
|
+
color: COMMENT_DISCORD_COLOR,
|
|
551
|
+
fields: [
|
|
552
|
+
{ name: "Feedback", value: escapeDiscordText(comment.feedbackId, DISCORD_FIELD_VALUE_MAX), inline: true },
|
|
553
|
+
{ name: "Author", value: author, inline: true }
|
|
554
|
+
],
|
|
555
|
+
timestamp: new Date(comment.createdAt).toISOString()
|
|
556
|
+
}
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
function buildGenericPayload(feedback) {
|
|
560
|
+
const { clientId: _clientId, comments, ...payload } = feedback;
|
|
561
|
+
if (!comments) return payload;
|
|
562
|
+
return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
|
|
563
|
+
}
|
|
564
|
+
function buildGenericCommentPayload(comment) {
|
|
565
|
+
const { clientId: _clientId, ...payload } = comment;
|
|
566
|
+
return payload;
|
|
567
|
+
}
|
|
568
|
+
function buildDeliveryPayload(type, delivery) {
|
|
569
|
+
switch (delivery.event) {
|
|
570
|
+
case "feedback.created":
|
|
571
|
+
if (type === "slack") return buildSlackPayload(delivery.feedback);
|
|
572
|
+
if (type === "discord") return buildDiscordPayload(delivery.feedback);
|
|
573
|
+
return buildGenericPayload(delivery.feedback);
|
|
574
|
+
case "feedback.status_changed":
|
|
575
|
+
if (type === "slack") return buildSlackStatusPayload(delivery.feedback);
|
|
576
|
+
if (type === "discord") return buildDiscordStatusPayload(delivery.feedback);
|
|
577
|
+
return buildGenericPayload(delivery.feedback);
|
|
578
|
+
case "feedback.deleted":
|
|
579
|
+
if (type === "slack") return buildSlackDeletionPayload(delivery.target);
|
|
580
|
+
if (type === "discord") return buildDiscordDeletionPayload(delivery.target);
|
|
581
|
+
return { ...delivery.target };
|
|
582
|
+
case "comment.created":
|
|
583
|
+
if (type === "slack") return buildSlackCommentPayload(delivery.comment);
|
|
584
|
+
if (type === "discord") return buildDiscordCommentPayload(delivery.comment);
|
|
585
|
+
return buildGenericCommentPayload(delivery.comment);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function dispatchWebhook(config, feedback) {
|
|
589
|
+
return deliver(config, { event: "feedback.created", feedback });
|
|
590
|
+
}
|
|
591
|
+
async function deliver(config, delivery) {
|
|
592
|
+
const problem = timeoutProblem(config.timeoutMs);
|
|
593
|
+
if (problem) return reportError(config, new RangeError(problem), delivery);
|
|
594
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
595
|
+
const controller = new AbortController();
|
|
596
|
+
let timer;
|
|
597
|
+
try {
|
|
598
|
+
const body = JSON.stringify(buildDeliveryPayload(config.type ?? "generic", delivery));
|
|
599
|
+
const custom = config.headers ?? {};
|
|
600
|
+
const customNames = new Set(Object.keys(custom).map((name) => name.toLowerCase()));
|
|
601
|
+
const headers = {
|
|
602
|
+
...customNames.has("content-type") ? {} : { "Content-Type": "application/json" },
|
|
603
|
+
...customNames.has(EVENT_HEADER.toLowerCase()) ? {} : { [EVENT_HEADER]: delivery.event },
|
|
604
|
+
...custom
|
|
605
|
+
};
|
|
606
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
607
|
+
const response = await fetch(config.url, {
|
|
608
|
+
method: "POST",
|
|
609
|
+
headers,
|
|
610
|
+
body,
|
|
611
|
+
signal: controller.signal
|
|
612
|
+
});
|
|
613
|
+
clearTimeout(timer);
|
|
614
|
+
if (!response.ok) {
|
|
615
|
+
const err = new Error(`Webhook responded with HTTP ${response.status}`);
|
|
616
|
+
await reportError(config, err, delivery);
|
|
617
|
+
}
|
|
618
|
+
} catch (rawError) {
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
const err = rawError instanceof Error ? rawError : new Error(String(rawError));
|
|
621
|
+
await reportError(config, err, delivery);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function reportError(config, err, delivery) {
|
|
625
|
+
const { feedbackId, projectName } = deliverySubject(delivery);
|
|
626
|
+
const subject = feedbackId ? `feedback ${feedbackId}` : `project ${projectName}`;
|
|
627
|
+
if (config.onError) {
|
|
628
|
+
try {
|
|
629
|
+
await config.onError(err, feedbackId, { event: delivery.event, projectName });
|
|
630
|
+
} catch (callbackErr) {
|
|
631
|
+
warnWithoutUrl(
|
|
632
|
+
config.url,
|
|
633
|
+
`webhook onError() callback threw for ${subject}: ${String(callbackErr)} (original error: ${err.message})`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
warnWithoutUrl(config.url, `webhook to ${webhookOrigin(config.url)} failed for ${subject}: ${err.message}`);
|
|
639
|
+
}
|
|
640
|
+
function webhookOrigin(url) {
|
|
641
|
+
try {
|
|
642
|
+
return new URL(url).origin;
|
|
643
|
+
} catch {
|
|
644
|
+
return "<invalid URL>";
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function warnWithoutUrl(url, message) {
|
|
648
|
+
console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
|
|
649
|
+
}
|
|
650
|
+
async function dispatchWebhooks(configs, feedback) {
|
|
651
|
+
await dispatchDelivery(configs, { event: "feedback.created", feedback });
|
|
652
|
+
}
|
|
653
|
+
async function dispatchDelivery(configs, delivery) {
|
|
654
|
+
const subscribed = configs.filter((config) => subscribesTo(config, delivery.event));
|
|
655
|
+
if (subscribed.length === 0) return;
|
|
656
|
+
await Promise.all(subscribed.map((config) => deliver(config, delivery)));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/notify.ts
|
|
660
|
+
function createNotifier(webhooks, waitUntil, pipeline) {
|
|
661
|
+
return (scope, delivery) => {
|
|
662
|
+
if (!webhooks.some((config) => subscribesTo(config, delivery.event))) return;
|
|
663
|
+
const pending = dispatchDelivery(webhooks, delivery);
|
|
664
|
+
try {
|
|
665
|
+
waitUntil?.(pending);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
const { feedbackId, projectName } = deliverySubject(delivery);
|
|
668
|
+
pipeline.logError(scope, "[siteping] waitUntil failed", {
|
|
669
|
+
error,
|
|
670
|
+
event: delivery.event,
|
|
671
|
+
...feedbackId ? { feedbackId } : {},
|
|
672
|
+
projectName
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
270
678
|
// src/validation.ts
|
|
271
679
|
import * as zod from "zod";
|
|
272
680
|
var z2 = "z" in zod ? zod.z : zod;
|
|
@@ -330,9 +738,9 @@ var screenshotRegionSchema = z2.strictObject({
|
|
|
330
738
|
wPct: z2.number().min(0).max(1),
|
|
331
739
|
hPct: z2.number().min(0).max(1)
|
|
332
740
|
});
|
|
333
|
-
var clientIdSchema = z2.string().min(1).max(
|
|
741
|
+
var clientIdSchema = z2.string().min(1).max(CLIENT_ID_MAX_LENGTH).regex(/^[a-zA-Z0-9_-]+$/, "clientId must be alphanumeric (a-z, A-Z, 0-9, _, -)");
|
|
334
742
|
var feedbackCreateSchema = z2.object({
|
|
335
|
-
projectName: z2.string().min(1).max(
|
|
743
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
336
744
|
type: z2.enum(FEEDBACK_TYPES),
|
|
337
745
|
message: z2.string().min(1).max(5e3),
|
|
338
746
|
// Page-scope identifier the widget uses to group feedbacks. Defaults to
|
|
@@ -370,7 +778,7 @@ var feedbackCreateSchema = z2.object({
|
|
|
370
778
|
diagnostics: diagnosticsSchema.nullable().optional()
|
|
371
779
|
});
|
|
372
780
|
var commentCreateSchema = z2.object({
|
|
373
|
-
projectName: z2.string().min(1).max(
|
|
781
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
374
782
|
feedbackId: z2.string().min(1).max(200),
|
|
375
783
|
// Trimmed, so a whitespace-only reply is a 400 rather than an empty bubble.
|
|
376
784
|
body: z2.string().trim().min(1).max(COMMENT_BODY_MAX_LENGTH),
|
|
@@ -384,21 +792,21 @@ var commentCreateSchema = z2.object({
|
|
|
384
792
|
clientId: clientIdSchema
|
|
385
793
|
});
|
|
386
794
|
var commentDeleteSchema = z2.object({
|
|
387
|
-
projectName: z2.string().min(1).max(
|
|
795
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
388
796
|
feedbackId: z2.string().min(1).max(200),
|
|
389
797
|
commentId: z2.string().min(1).max(200)
|
|
390
798
|
});
|
|
391
799
|
var feedbackPatchSchema = z2.object({
|
|
392
800
|
id: z2.string().min(1),
|
|
393
|
-
projectName: z2.string().min(1).max(
|
|
801
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
394
802
|
status: z2.enum(FEEDBACK_STATUSES)
|
|
395
803
|
});
|
|
396
804
|
var feedbackDeleteSchema = z2.union([
|
|
397
|
-
z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(
|
|
398
|
-
z2.object({ projectName: z2.string().min(1).max(
|
|
805
|
+
z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH) }),
|
|
806
|
+
z2.object({ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH), deleteAll: z2.literal(true) })
|
|
399
807
|
]);
|
|
400
808
|
var getQuerySchema = z2.object({
|
|
401
|
-
projectName: z2.string().min(1).max(
|
|
809
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
402
810
|
page: z2.coerce.number().int().min(1).default(1),
|
|
403
811
|
limit: z2.coerce.number().int().min(1).max(100).default(50),
|
|
404
812
|
type: z2.enum(FEEDBACK_TYPES).optional(),
|
|
@@ -427,7 +835,9 @@ function formatValidationErrors(error) {
|
|
|
427
835
|
function createCommentOperation({
|
|
428
836
|
store,
|
|
429
837
|
pipeline,
|
|
430
|
-
|
|
838
|
+
notify,
|
|
839
|
+
beforeComment,
|
|
840
|
+
onCommentCreated
|
|
431
841
|
}) {
|
|
432
842
|
return async (scope, body) => {
|
|
433
843
|
const payload = pipeline.validate(scope, commentCreateSchema, body);
|
|
@@ -440,12 +850,25 @@ function createCommentOperation({
|
|
|
440
850
|
if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
|
|
441
851
|
return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
442
852
|
}
|
|
443
|
-
const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope) ? "team" : "client";
|
|
444
|
-
const
|
|
853
|
+
const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope, projectName) ? "team" : "client";
|
|
854
|
+
const input = { ...comment, authorRole: role };
|
|
855
|
+
const { comment: stored, created } = store.addCommentIfAbsent ? await store.addCommentIfAbsent(feedbackId, input) : { comment: await store.addComment(feedbackId, input), created: false };
|
|
445
856
|
if (stored.feedbackId !== feedbackId) {
|
|
446
857
|
return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherFeedback);
|
|
447
858
|
}
|
|
448
|
-
|
|
859
|
+
if (created) {
|
|
860
|
+
const reply = { ...stored, projectName };
|
|
861
|
+
notify(scope, { event: "comment.created", comment: reply });
|
|
862
|
+
if (onCommentCreated) {
|
|
863
|
+
await pipeline.runHook(
|
|
864
|
+
scope,
|
|
865
|
+
"onCommentCreated",
|
|
866
|
+
{ feedbackId, projectName, commentId: stored.id },
|
|
867
|
+
() => onCommentCreated(reply, scope.context)
|
|
868
|
+
);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return pipeline.json(scope, await pipeline.presentCreatedComment(scope, stored, projectName), { status: 201 });
|
|
449
872
|
} catch (error) {
|
|
450
873
|
if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
451
874
|
if (isStoreLimit(error)) return pipeline.error(scope, 409, ERROR_MESSAGES.tooManyComments);
|
|
@@ -455,209 +878,6 @@ function createCommentOperation({
|
|
|
455
878
|
};
|
|
456
879
|
}
|
|
457
880
|
|
|
458
|
-
// src/webhooks.ts
|
|
459
|
-
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
460
|
-
var TIMER_MAX_DELAY_MS = 2147483647;
|
|
461
|
-
function timeoutProblem(timeoutMs) {
|
|
462
|
-
if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
|
|
463
|
-
return null;
|
|
464
|
-
}
|
|
465
|
-
return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
|
|
466
|
-
}
|
|
467
|
-
function checkWebhookTimeouts(configs) {
|
|
468
|
-
for (const config of configs) {
|
|
469
|
-
const problem = timeoutProblem(config.timeoutMs);
|
|
470
|
-
if (problem) {
|
|
471
|
-
throw new Error(`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
var DISCORD_COLORS = {
|
|
476
|
-
bug: 15680580,
|
|
477
|
-
question: 3900150,
|
|
478
|
-
change: 16096779,
|
|
479
|
-
other: 7041664
|
|
480
|
-
};
|
|
481
|
-
var DEFAULT_DISCORD_COLOR = 7041664;
|
|
482
|
-
function truncate(text, max = 300) {
|
|
483
|
-
if (text.length <= max) return text;
|
|
484
|
-
return `${text.slice(0, max - 1)}\u2026`;
|
|
485
|
-
}
|
|
486
|
-
function fitEscaped(units, max) {
|
|
487
|
-
const escaped = units.join("");
|
|
488
|
-
if (escaped.length <= max) return escaped;
|
|
489
|
-
let out = "";
|
|
490
|
-
for (const unit of units) {
|
|
491
|
-
if (out.length + unit.length > max - 1) break;
|
|
492
|
-
out += unit;
|
|
493
|
-
}
|
|
494
|
-
return `${out}\u2026`;
|
|
495
|
-
}
|
|
496
|
-
var SLACK_HEADER_MAX = 150;
|
|
497
|
-
var SLACK_TEXT_MAX = 3e3;
|
|
498
|
-
function escapeSlackText(text) {
|
|
499
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
500
|
-
}
|
|
501
|
-
function buildSlackPayload(feedback) {
|
|
502
|
-
const preview = escapeSlackText(truncate(feedback.message));
|
|
503
|
-
const escapeField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
|
|
504
|
-
const fromHalf = Math.floor((SLACK_TEXT_MAX - 11) / 2);
|
|
505
|
-
const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;
|
|
506
|
-
return {
|
|
507
|
-
text: `${escapeSlackText(headline)}: ${preview}`,
|
|
508
|
-
blocks: [
|
|
509
|
-
{
|
|
510
|
-
type: "header",
|
|
511
|
-
text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
|
|
512
|
-
},
|
|
513
|
-
{
|
|
514
|
-
type: "section",
|
|
515
|
-
text: { type: "mrkdwn", text: preview }
|
|
516
|
-
},
|
|
517
|
-
{
|
|
518
|
-
type: "context",
|
|
519
|
-
elements: [
|
|
520
|
-
{ type: "mrkdwn", text: `*Project:* ${escapeField(feedback.projectName, SLACK_TEXT_MAX - 11)}` },
|
|
521
|
-
{ type: "mrkdwn", text: `*Type:* ${feedback.type}` },
|
|
522
|
-
{ type: "mrkdwn", text: `*URL:* ${escapeField(feedback.url, SLACK_TEXT_MAX - 7)}` },
|
|
523
|
-
{
|
|
524
|
-
type: "mrkdwn",
|
|
525
|
-
text: `*From:* ${escapeField(feedback.authorName, fromHalf)} (${escapeField(feedback.authorEmail, fromHalf)})`
|
|
526
|
-
}
|
|
527
|
-
]
|
|
528
|
-
}
|
|
529
|
-
]
|
|
530
|
-
};
|
|
531
|
-
}
|
|
532
|
-
var DISCORD_CONTENT_MAX = 2e3;
|
|
533
|
-
var DISCORD_TITLE_MAX = 256;
|
|
534
|
-
var DISCORD_FIELD_VALUE_MAX = 1024;
|
|
535
|
-
var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
|
|
536
|
-
var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
|
|
537
|
-
var DISCORD_LINK_TRAILER = `.,:;"')]`;
|
|
538
|
-
var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
|
|
539
|
-
var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
540
|
-
var count = (text, char) => text.split(char).length - 1;
|
|
541
|
-
function discordUrlUnits(url) {
|
|
542
|
-
const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
|
|
543
|
-
let end = url.length;
|
|
544
|
-
while (end > 0) {
|
|
545
|
-
const last = url.charAt(end - 1);
|
|
546
|
-
if (!DISCORD_LINK_TRAILER.includes(last)) break;
|
|
547
|
-
if (last === ")" || last === "]") {
|
|
548
|
-
if (open[last] >= 0) break;
|
|
549
|
-
open[last] += 1;
|
|
550
|
-
}
|
|
551
|
-
end -= 1;
|
|
552
|
-
}
|
|
553
|
-
return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
|
|
554
|
-
}
|
|
555
|
-
function escapeDiscordText(text, max) {
|
|
556
|
-
const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
|
|
557
|
-
return fitEscaped(units, max);
|
|
558
|
-
}
|
|
559
|
-
function buildDiscordPayload(feedback) {
|
|
560
|
-
const contentLead = `New **${feedback.type}** feedback from **`;
|
|
561
|
-
const titleLead = `${feedback.type} \u2014 `;
|
|
562
|
-
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
563
|
-
return {
|
|
564
|
-
content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
565
|
-
embeds: [
|
|
566
|
-
{
|
|
567
|
-
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
568
|
-
description: escapeDiscordText(feedback.message, 300),
|
|
569
|
-
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
570
|
-
fields: [
|
|
571
|
-
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
|
|
572
|
-
{
|
|
573
|
-
name: "Author",
|
|
574
|
-
value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
|
|
575
|
-
inline: true
|
|
576
|
-
},
|
|
577
|
-
{ name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
|
|
578
|
-
],
|
|
579
|
-
timestamp: new Date(feedback.createdAt).toISOString()
|
|
580
|
-
}
|
|
581
|
-
],
|
|
582
|
-
allowed_mentions: { parse: [] }
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
function buildGenericPayload(feedback) {
|
|
586
|
-
const { clientId: _clientId, comments, ...payload } = feedback;
|
|
587
|
-
if (!comments) return payload;
|
|
588
|
-
return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
|
|
589
|
-
}
|
|
590
|
-
function buildWebhookPayload(type, feedback) {
|
|
591
|
-
switch (type) {
|
|
592
|
-
case "slack":
|
|
593
|
-
return buildSlackPayload(feedback);
|
|
594
|
-
case "discord":
|
|
595
|
-
return buildDiscordPayload(feedback);
|
|
596
|
-
default:
|
|
597
|
-
return buildGenericPayload(feedback);
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
async function dispatchWebhook(config, feedback) {
|
|
601
|
-
const problem = timeoutProblem(config.timeoutMs);
|
|
602
|
-
if (problem) return reportError(config, new RangeError(problem), feedback.id);
|
|
603
|
-
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
604
|
-
const controller = new AbortController();
|
|
605
|
-
let timer;
|
|
606
|
-
try {
|
|
607
|
-
const body = JSON.stringify(buildWebhookPayload(config.type ?? "generic", feedback));
|
|
608
|
-
const custom = config.headers ?? {};
|
|
609
|
-
const setsContentType = Object.keys(custom).some((name) => name.toLowerCase() === "content-type");
|
|
610
|
-
const headers = setsContentType ? custom : { "Content-Type": "application/json", ...custom };
|
|
611
|
-
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
612
|
-
const response = await fetch(config.url, {
|
|
613
|
-
method: "POST",
|
|
614
|
-
headers,
|
|
615
|
-
body,
|
|
616
|
-
signal: controller.signal
|
|
617
|
-
});
|
|
618
|
-
clearTimeout(timer);
|
|
619
|
-
if (!response.ok) {
|
|
620
|
-
const err = new Error(`Webhook responded with HTTP ${response.status}`);
|
|
621
|
-
await reportError(config, err, feedback.id);
|
|
622
|
-
}
|
|
623
|
-
} catch (rawError) {
|
|
624
|
-
clearTimeout(timer);
|
|
625
|
-
const err = rawError instanceof Error ? rawError : new Error(String(rawError));
|
|
626
|
-
await reportError(config, err, feedback.id);
|
|
627
|
-
}
|
|
628
|
-
}
|
|
629
|
-
async function reportError(config, err, feedbackId) {
|
|
630
|
-
if (config.onError) {
|
|
631
|
-
try {
|
|
632
|
-
await config.onError(err, feedbackId);
|
|
633
|
-
} catch (callbackErr) {
|
|
634
|
-
warnWithoutUrl(
|
|
635
|
-
config.url,
|
|
636
|
-
`webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
warnWithoutUrl(
|
|
642
|
-
config.url,
|
|
643
|
-
`webhook to ${webhookOrigin(config.url)} failed for feedback ${feedbackId}: ${err.message}`
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
function webhookOrigin(url) {
|
|
647
|
-
try {
|
|
648
|
-
return new URL(url).origin;
|
|
649
|
-
} catch {
|
|
650
|
-
return "<invalid URL>";
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
function warnWithoutUrl(url, message) {
|
|
654
|
-
console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
|
|
655
|
-
}
|
|
656
|
-
async function dispatchWebhooks(configs, feedback) {
|
|
657
|
-
if (configs.length === 0) return;
|
|
658
|
-
await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));
|
|
659
|
-
}
|
|
660
|
-
|
|
661
881
|
// src/operations/create-feedback.ts
|
|
662
882
|
var FAILED_TO_CREATE = "[siteping] Failed to create feedback";
|
|
663
883
|
function toCreateInput(data) {
|
|
@@ -682,8 +902,7 @@ function toCreateInput(data) {
|
|
|
682
902
|
function createFeedbackOperation({
|
|
683
903
|
store,
|
|
684
904
|
pipeline,
|
|
685
|
-
|
|
686
|
-
waitUntil,
|
|
905
|
+
notify,
|
|
687
906
|
beforeCreate,
|
|
688
907
|
onCreated
|
|
689
908
|
}) {
|
|
@@ -743,14 +962,7 @@ function createFeedbackOperation({
|
|
|
743
962
|
const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
|
|
744
963
|
try {
|
|
745
964
|
if (owner && inserted && feedback.projectName === input.projectName) {
|
|
746
|
-
|
|
747
|
-
const delivery = dispatchWebhooks(webhooks, feedback);
|
|
748
|
-
try {
|
|
749
|
-
waitUntil?.(delivery);
|
|
750
|
-
} catch (error) {
|
|
751
|
-
pipeline.logError(scope, "[siteping] waitUntil failed", { error, ...subject });
|
|
752
|
-
}
|
|
753
|
-
}
|
|
965
|
+
notify(scope, { event: "feedback.created", feedback });
|
|
754
966
|
if (onCreated) await pipeline.runHook(scope, "onCreated", subject, () => onCreated(feedback, scope.context));
|
|
755
967
|
}
|
|
756
968
|
if (feedback.projectName !== input.projectName) {
|
|
@@ -789,6 +1001,7 @@ function deleteCommentOperation({ store, pipeline }) {
|
|
|
789
1001
|
function deleteFeedbackOperation({
|
|
790
1002
|
store,
|
|
791
1003
|
pipeline,
|
|
1004
|
+
notify,
|
|
792
1005
|
onDeleting,
|
|
793
1006
|
onDeleted
|
|
794
1007
|
}) {
|
|
@@ -815,6 +1028,7 @@ function deleteFeedbackOperation({
|
|
|
815
1028
|
}
|
|
816
1029
|
if (target.kind === "project") await store.deleteAllFeedbacks(target.projectName);
|
|
817
1030
|
else await store.deleteFeedback(target.id);
|
|
1031
|
+
notify(scope, { event: "feedback.deleted", target });
|
|
818
1032
|
if (onDeleted) await pipeline.runHook(scope, "onDeleted", { target }, () => onDeleted(target, scope.context));
|
|
819
1033
|
return pipeline.json(scope, { deleted: true });
|
|
820
1034
|
} catch (error) {
|
|
@@ -855,6 +1069,8 @@ function listFeedbacksOperation({ store, pipeline }) {
|
|
|
855
1069
|
{
|
|
856
1070
|
...page,
|
|
857
1071
|
feedbacks,
|
|
1072
|
+
// Lets clients rely on what a protocol version added instead of probing for fields.
|
|
1073
|
+
protocol: SITEPING_PROTOCOL_VERSION,
|
|
858
1074
|
// Lets clients hide their comment composer, and delete buttons, up front instead of meeting a 501.
|
|
859
1075
|
capabilities: {
|
|
860
1076
|
comments: store.addComment !== void 0,
|
|
@@ -875,6 +1091,7 @@ function listFeedbacksOperation({ store, pipeline }) {
|
|
|
875
1091
|
function updateFeedbackOperation({
|
|
876
1092
|
store,
|
|
877
1093
|
pipeline,
|
|
1094
|
+
notify,
|
|
878
1095
|
onUpdated
|
|
879
1096
|
}) {
|
|
880
1097
|
return async (request) => {
|
|
@@ -891,6 +1108,7 @@ function updateFeedbackOperation({
|
|
|
891
1108
|
return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
892
1109
|
}
|
|
893
1110
|
const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));
|
|
1111
|
+
notify(scope, { event: "feedback.status_changed", feedback });
|
|
894
1112
|
if (onUpdated) {
|
|
895
1113
|
const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
|
|
896
1114
|
await pipeline.runHook(scope, "onUpdated", subject, () => onUpdated(feedback, scope.context));
|
|
@@ -1033,14 +1251,26 @@ function createPipeline({
|
|
|
1033
1251
|
]);
|
|
1034
1252
|
return { canChangeStatus, canDelete, canComment, canDeleteComment };
|
|
1035
1253
|
};
|
|
1036
|
-
const
|
|
1037
|
-
...
|
|
1038
|
-
presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
|
|
1039
|
-
includeEmail,
|
|
1040
|
-
scope.canReadAuthorEmail
|
|
1041
|
-
),
|
|
1042
|
-
permissions: await permissions(scope, feedback)
|
|
1254
|
+
const commentPermissions = async (scope, thread, commentId, canDeleteThread) => ({
|
|
1255
|
+
canDelete: canDeleteThread || await may(scope, "DELETE", { action: "deleteComment", ...thread, commentId })
|
|
1043
1256
|
});
|
|
1257
|
+
const present = async (scope, feedback, echoEmail = false) => {
|
|
1258
|
+
const canReadEmails = await scope.canReadAuthorEmail(feedback.projectName);
|
|
1259
|
+
const wire = toWireFeedback(
|
|
1260
|
+
presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
|
|
1261
|
+
echoEmail || canReadEmails,
|
|
1262
|
+
canReadEmails
|
|
1263
|
+
);
|
|
1264
|
+
const granted = await permissions(scope, feedback);
|
|
1265
|
+
const thread = { projectName: feedback.projectName, feedbackId: feedback.id };
|
|
1266
|
+
const comments = await Promise.all(
|
|
1267
|
+
wire.comments.map(async (comment) => ({
|
|
1268
|
+
...comment,
|
|
1269
|
+
permissions: await commentPermissions(scope, thread, comment.id, granted.canDeleteComment)
|
|
1270
|
+
}))
|
|
1271
|
+
);
|
|
1272
|
+
return { ...wire, comments, permissions: granted };
|
|
1273
|
+
};
|
|
1044
1274
|
return {
|
|
1045
1275
|
json,
|
|
1046
1276
|
error,
|
|
@@ -1076,12 +1306,21 @@ function createPipeline({
|
|
|
1076
1306
|
};
|
|
1077
1307
|
}
|
|
1078
1308
|
if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
|
|
1309
|
+
const context = { request, principal: outcome.principal };
|
|
1310
|
+
const emailAccess = /* @__PURE__ */ new Map();
|
|
1079
1311
|
return {
|
|
1080
1312
|
ok: true,
|
|
1081
1313
|
value: {
|
|
1082
|
-
context
|
|
1314
|
+
context,
|
|
1083
1315
|
corsHeaders,
|
|
1084
|
-
canReadAuthorEmail
|
|
1316
|
+
canReadAuthorEmail(projectName) {
|
|
1317
|
+
let answer = emailAccess.get(projectName);
|
|
1318
|
+
if (!answer) {
|
|
1319
|
+
answer = gate.canReadAuthorEmail(context, projectName);
|
|
1320
|
+
emailAccess.set(projectName, answer);
|
|
1321
|
+
}
|
|
1322
|
+
return answer;
|
|
1323
|
+
}
|
|
1085
1324
|
}
|
|
1086
1325
|
};
|
|
1087
1326
|
},
|
|
@@ -1091,9 +1330,16 @@ function createPipeline({
|
|
|
1091
1330
|
const body = await readJson(scope);
|
|
1092
1331
|
return body.ok ? validate(scope, schema, body.value) : body;
|
|
1093
1332
|
},
|
|
1094
|
-
/**
|
|
1333
|
+
/**
|
|
1334
|
+
* `null` when the policy allows the request, its 403 otherwise. An
|
|
1335
|
+
* allowed request also settles its email access for the project now,
|
|
1336
|
+
* before anything is written: a throwing `canReadAuthorEmail` answers
|
|
1337
|
+
* the operation's 500 with nothing stored.
|
|
1338
|
+
*/
|
|
1095
1339
|
async authorize(scope, target) {
|
|
1096
|
-
|
|
1340
|
+
if (!await gate.authorize({ ...scope.context, ...target })) return error(scope, 403, ERROR_MESSAGES.forbidden);
|
|
1341
|
+
await scope.canReadAuthorEmail(target.projectName);
|
|
1342
|
+
return null;
|
|
1097
1343
|
},
|
|
1098
1344
|
/**
|
|
1099
1345
|
* Wire shape of a record answering a POST (fresh or replayed): the
|
|
@@ -1101,15 +1347,29 @@ function createPipeline({
|
|
|
1101
1347
|
* its submitter — who supplied the feedback's, never its thread's.
|
|
1102
1348
|
*/
|
|
1103
1349
|
presentCreated(scope, feedback) {
|
|
1104
|
-
return present(scope, feedback, gate.echoesAuthorEmailOnCreate
|
|
1350
|
+
return present(scope, feedback, gate.echoesAuthorEmailOnCreate);
|
|
1105
1351
|
},
|
|
1106
|
-
/**
|
|
1107
|
-
|
|
1108
|
-
|
|
1352
|
+
/**
|
|
1353
|
+
* Wire shape of a comment answering a POST, in `projectName`'s thread —
|
|
1354
|
+
* the same echo rule as {@link presentCreated}, with its permissions.
|
|
1355
|
+
*/
|
|
1356
|
+
async presentCreatedComment(scope, comment, projectName) {
|
|
1357
|
+
const thread = { projectName, feedbackId: comment.feedbackId };
|
|
1358
|
+
const [canReadEmails, canDeleteThread] = await Promise.all([
|
|
1359
|
+
scope.canReadAuthorEmail(projectName),
|
|
1360
|
+
may(scope, "DELETE", { action: "deleteComment", ...thread })
|
|
1361
|
+
]);
|
|
1362
|
+
return {
|
|
1363
|
+
...toWireComment(comment, gate.echoesAuthorEmailOnCreate || canReadEmails),
|
|
1364
|
+
permissions: await commentPermissions(scope, thread, comment.id, canDeleteThread)
|
|
1365
|
+
};
|
|
1109
1366
|
},
|
|
1110
|
-
/**
|
|
1111
|
-
|
|
1112
|
-
|
|
1367
|
+
/**
|
|
1368
|
+
* Whether this caller's comment in `projectName` keeps the `team` role it
|
|
1369
|
+
* asks for — see `AccessGate.canCommentAsTeam`.
|
|
1370
|
+
*/
|
|
1371
|
+
async canCommentAsTeam(scope, projectName) {
|
|
1372
|
+
return gate.canCommentAsTeam(scope.context, projectName, await scope.canReadAuthorEmail(projectName));
|
|
1113
1373
|
},
|
|
1114
1374
|
/**
|
|
1115
1375
|
* Answer a value the store cannot hold with a 422, which a client does
|
|
@@ -1179,15 +1439,17 @@ function createSitepingHandler(options) {
|
|
|
1179
1439
|
maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
1180
1440
|
} = options;
|
|
1181
1441
|
if (!store) {
|
|
1182
|
-
throw new
|
|
1442
|
+
throw new SitepingConfigError("CONFIG_STORE_MISSING", "[siteping] createSitepingHandler requires a `store`.");
|
|
1183
1443
|
}
|
|
1184
1444
|
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
|
|
1185
|
-
throw new
|
|
1445
|
+
throw new SitepingConfigError(
|
|
1446
|
+
"CONFIG_MAX_BODY_BYTES_INVALID",
|
|
1186
1447
|
`[siteping] createSitepingHandler: \`maxBodyBytes\` must be a positive integer, got ${maxBodyBytes}.`
|
|
1187
1448
|
);
|
|
1188
1449
|
}
|
|
1189
1450
|
if (options.access?.authorize && !store.verifyProjectOwnership) {
|
|
1190
|
-
throw new
|
|
1451
|
+
throw new SitepingConfigError(
|
|
1452
|
+
"CONFIG_OWNERSHIP_CHECK_MISSING",
|
|
1191
1453
|
"[siteping] createSitepingHandler: `access.authorize` needs a store implementing `verifyProjectOwnership`. Without it, a caller authorized for one project could PATCH or DELETE another project's feedback by id."
|
|
1192
1454
|
);
|
|
1193
1455
|
}
|
|
@@ -1195,26 +1457,37 @@ function createSitepingHandler(options) {
|
|
|
1195
1457
|
const logger = customLogger ? safeLogger(customLogger) : consoleLogger;
|
|
1196
1458
|
const pipeline = createPipeline({ gate, allowedOrigins, logger, describeError, presentFeedback, maxBodyBytes });
|
|
1197
1459
|
const webhookList = webhooks ? Array.isArray(webhooks) ? webhooks : [webhooks] : [];
|
|
1198
|
-
|
|
1460
|
+
checkWebhookConfigs(webhookList);
|
|
1461
|
+
if (!store.addCommentIfAbsent && (hooks.onCommentCreated || webhookList.some((config) => subscribesTo(config, "comment.created")))) {
|
|
1462
|
+
throw new Error(
|
|
1463
|
+
"[siteping] createSitepingHandler: `comment.created` webhooks and the `onCommentCreated` hook need a store implementing `addCommentIfAbsent`, which tells a new reply from a retried one."
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
const notify = createNotifier(webhookList, waitUntil, pipeline);
|
|
1199
1467
|
return {
|
|
1200
1468
|
OPTIONS: (request) => preflightResponse(request, allowedOrigins),
|
|
1201
1469
|
POST: routeByBody(
|
|
1202
1470
|
pipeline,
|
|
1203
1471
|
"POST",
|
|
1204
1472
|
"feedbackId",
|
|
1205
|
-
createCommentOperation({
|
|
1473
|
+
createCommentOperation({
|
|
1474
|
+
store,
|
|
1475
|
+
pipeline,
|
|
1476
|
+
notify,
|
|
1477
|
+
beforeComment,
|
|
1478
|
+
onCommentCreated: hooks.onCommentCreated?.bind(hooks)
|
|
1479
|
+
}),
|
|
1206
1480
|
createFeedbackOperation({
|
|
1207
1481
|
store,
|
|
1208
1482
|
pipeline,
|
|
1209
|
-
|
|
1210
|
-
waitUntil,
|
|
1483
|
+
notify,
|
|
1211
1484
|
beforeCreate,
|
|
1212
1485
|
// Bound so hooks written as class methods keep their `this`.
|
|
1213
1486
|
onCreated: hooks.onCreated?.bind(hooks)
|
|
1214
1487
|
})
|
|
1215
1488
|
),
|
|
1216
1489
|
GET: listFeedbacksOperation({ store, pipeline }),
|
|
1217
|
-
PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
|
|
1490
|
+
PATCH: updateFeedbackOperation({ store, pipeline, notify, onUpdated: hooks.onUpdated?.bind(hooks) }),
|
|
1218
1491
|
DELETE: routeByBody(
|
|
1219
1492
|
pipeline,
|
|
1220
1493
|
"DELETE",
|
|
@@ -1223,6 +1496,7 @@ function createSitepingHandler(options) {
|
|
|
1223
1496
|
deleteFeedbackOperation({
|
|
1224
1497
|
store,
|
|
1225
1498
|
pipeline,
|
|
1499
|
+
notify,
|
|
1226
1500
|
onDeleting: hooks.onDeleting?.bind(hooks),
|
|
1227
1501
|
onDeleted: hooks.onDeleted?.bind(hooks)
|
|
1228
1502
|
})
|
|
@@ -1230,8 +1504,11 @@ function createSitepingHandler(options) {
|
|
|
1230
1504
|
};
|
|
1231
1505
|
}
|
|
1232
1506
|
export {
|
|
1507
|
+
SITEPING_PROTOCOL_VERSION,
|
|
1508
|
+
WEBHOOK_EVENTS,
|
|
1233
1509
|
createSitepingHandler,
|
|
1234
1510
|
dispatchWebhook,
|
|
1235
|
-
dispatchWebhooks
|
|
1511
|
+
dispatchWebhooks,
|
|
1512
|
+
isSitepingConfigError
|
|
1236
1513
|
};
|
|
1237
1514
|
//# sourceMappingURL=index.js.map
|