@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.cjs
CHANGED
|
@@ -30,9 +30,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var index_exports = {};
|
|
32
32
|
__export(index_exports, {
|
|
33
|
+
SITEPING_PROTOCOL_VERSION: () => SITEPING_PROTOCOL_VERSION,
|
|
34
|
+
WEBHOOK_EVENTS: () => WEBHOOK_EVENTS,
|
|
33
35
|
createSitepingHandler: () => createSitepingHandler,
|
|
34
36
|
dispatchWebhook: () => dispatchWebhook,
|
|
35
|
-
dispatchWebhooks: () => dispatchWebhooks
|
|
37
|
+
dispatchWebhooks: () => dispatchWebhooks,
|
|
38
|
+
isSitepingConfigError: () => isSitepingConfigError
|
|
36
39
|
});
|
|
37
40
|
module.exports = __toCommonJS(index_exports);
|
|
38
41
|
|
|
@@ -47,8 +50,23 @@ function hasOwn(value, key) {
|
|
|
47
50
|
return isRecord(value) && key in value;
|
|
48
51
|
}
|
|
49
52
|
|
|
53
|
+
// ../core/src/errors.ts
|
|
54
|
+
var SitepingConfigError = class extends Error {
|
|
55
|
+
code;
|
|
56
|
+
constructor(code, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.code = code;
|
|
59
|
+
this.name = "SitepingConfigError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
function isSitepingConfigError(error) {
|
|
63
|
+
return hasOwn(error, "name") && error.name === "SitepingConfigError" && hasOwn(error, "code") && typeof error.code === "string" && error.code.startsWith("CONFIG_");
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
// ../core/src/types.ts
|
|
51
67
|
var IDENTITY_FIELD_MAX_LENGTH = 200;
|
|
68
|
+
var PROJECT_NAME_MAX_LENGTH = 191;
|
|
69
|
+
var CLIENT_ID_MAX_LENGTH = 191;
|
|
52
70
|
var FEEDBACK_TYPES = ["question", "change", "bug", "other"];
|
|
53
71
|
var FEEDBACK_STATUSES = ["open", "in_progress", "resolved", "wont_fix"];
|
|
54
72
|
var CLOSED_FEEDBACK_STATUSES = ["resolved", "wont_fix"];
|
|
@@ -134,6 +152,7 @@ function flattenAnnotation(ann) {
|
|
|
134
152
|
var CONSOLE_DIAGNOSTIC_LEVELS = ["log", "info", "warn", "error"];
|
|
135
153
|
var ANCHOR_ELEMENT_TAG_MAX = 191;
|
|
136
154
|
var ANCHOR_ELEMENT_ID_MAX = 191;
|
|
155
|
+
var SITEPING_PROTOCOL_VERSION = 1;
|
|
137
156
|
|
|
138
157
|
// src/constants.ts
|
|
139
158
|
var MAX_ANNOTATIONS_PER_FEEDBACK = 50;
|
|
@@ -192,7 +211,8 @@ function createApiKeyGate({
|
|
|
192
211
|
redactUnauthenticatedEmails = true
|
|
193
212
|
}) {
|
|
194
213
|
if (!apiKey && requireAuthForDestructive && isProductionEnvironment()) {
|
|
195
|
-
throw new
|
|
214
|
+
throw new SitepingConfigError(
|
|
215
|
+
"CONFIG_API_KEY_REQUIRED",
|
|
196
216
|
"[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."
|
|
197
217
|
);
|
|
198
218
|
}
|
|
@@ -203,15 +223,14 @@ function createApiKeyGate({
|
|
|
203
223
|
return header !== null && safeCompare(header, `Bearer ${apiKey}`);
|
|
204
224
|
};
|
|
205
225
|
const authenticate = async (request, method) => {
|
|
206
|
-
const canReadAuthorEmail = !redactUnauthenticatedEmails || isBearerAuthenticated(request);
|
|
207
226
|
if (!apiKey) {
|
|
208
227
|
if (requireAuthForDestructive && (method === "DELETE" || method === "PATCH")) {
|
|
209
228
|
return { ok: false, status: 401, error: ERROR_MESSAGES.apiKeyRequiredForDestructive };
|
|
210
229
|
}
|
|
211
|
-
return { ok: true, principal: null
|
|
230
|
+
return { ok: true, principal: null };
|
|
212
231
|
}
|
|
213
232
|
if (publicMethods?.has(method) || isBearerAuthenticated(request)) {
|
|
214
|
-
return { ok: true, principal: null
|
|
233
|
+
return { ok: true, principal: null };
|
|
215
234
|
}
|
|
216
235
|
return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
|
|
217
236
|
};
|
|
@@ -228,6 +247,10 @@ function createApiKeyGate({
|
|
|
228
247
|
async authorize() {
|
|
229
248
|
return true;
|
|
230
249
|
},
|
|
250
|
+
// One key covers every project, so the project changes nothing here.
|
|
251
|
+
async canReadAuthorEmail({ request }) {
|
|
252
|
+
return !redactUnauthenticatedEmails || isBearerAuthenticated(request);
|
|
253
|
+
},
|
|
231
254
|
// The key proves the caller is the project side; a public POST does not.
|
|
232
255
|
async canCommentAsTeam({ request }) {
|
|
233
256
|
return isBearerAuthenticated(request);
|
|
@@ -244,7 +267,7 @@ function createAccessGate(access) {
|
|
|
244
267
|
if (!principal) {
|
|
245
268
|
return { ok: false, status: 401, error: ERROR_MESSAGES.unauthorized };
|
|
246
269
|
}
|
|
247
|
-
return { ok: true, principal
|
|
270
|
+
return { ok: true, principal };
|
|
248
271
|
},
|
|
249
272
|
// A dry run reuses the response's request, so `authenticate` is never asked
|
|
250
273
|
// about another method: `authorize` decides, by action.
|
|
@@ -254,10 +277,15 @@ function createAccessGate(access) {
|
|
|
254
277
|
async authorize(context) {
|
|
255
278
|
return access.authorize ? access.authorize(context) : true;
|
|
256
279
|
},
|
|
280
|
+
// Emails are personal data: only a `true` answer reveals them — no
|
|
281
|
+
// callback, or `undefined` from a principal missing the flag, hides them.
|
|
282
|
+
async canReadAuthorEmail({ request, principal }, projectName) {
|
|
283
|
+
return await access.canReadAuthorEmail?.(principal, { request, projectName }) === true;
|
|
284
|
+
},
|
|
257
285
|
// Speaking as the team is an impersonation privilege: without the host's
|
|
258
286
|
// word for it (its own callback, or the email access it grants), refuse.
|
|
259
|
-
async canCommentAsTeam({ principal }, canReadAuthorEmail) {
|
|
260
|
-
return access.canCommentAsTeam ? await access.canCommentAsTeam(principal) === true : canReadAuthorEmail;
|
|
287
|
+
async canCommentAsTeam({ request, principal }, projectName, canReadAuthorEmail) {
|
|
288
|
+
return access.canCommentAsTeam ? await access.canCommentAsTeam(principal, { request, projectName }) === true : canReadAuthorEmail;
|
|
261
289
|
}
|
|
262
290
|
};
|
|
263
291
|
}
|
|
@@ -305,6 +333,389 @@ function preflightResponse(request, allowedOrigins) {
|
|
|
305
333
|
return new Response(null, { status: 204, headers });
|
|
306
334
|
}
|
|
307
335
|
|
|
336
|
+
// src/webhooks.ts
|
|
337
|
+
var WEBHOOK_EVENTS = [
|
|
338
|
+
"feedback.created",
|
|
339
|
+
"feedback.status_changed",
|
|
340
|
+
"feedback.deleted",
|
|
341
|
+
"comment.created"
|
|
342
|
+
];
|
|
343
|
+
var DEFAULT_EVENTS = ["feedback.created"];
|
|
344
|
+
var EVENT_HEADER = "X-Siteping-Event";
|
|
345
|
+
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
346
|
+
var TIMER_MAX_DELAY_MS = 2147483647;
|
|
347
|
+
function timeoutProblem(timeoutMs) {
|
|
348
|
+
if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
|
|
352
|
+
}
|
|
353
|
+
function eventsProblem(events) {
|
|
354
|
+
if (events === void 0) return null;
|
|
355
|
+
if (!Array.isArray(events) || events.length === 0) return "events must list at least one event";
|
|
356
|
+
const unknown = events.find((event) => !WEBHOOK_EVENTS.includes(event));
|
|
357
|
+
if (unknown !== void 0)
|
|
358
|
+
return `unknown event ${JSON.stringify(unknown)} in events (one of ${WEBHOOK_EVENTS.join(", ")})`;
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
function checkWebhookConfigs(configs) {
|
|
362
|
+
for (const config of configs) {
|
|
363
|
+
const problem = timeoutProblem(config.timeoutMs) ?? eventsProblem(config.events);
|
|
364
|
+
if (problem) {
|
|
365
|
+
throw new SitepingConfigError(
|
|
366
|
+
"CONFIG_WEBHOOK_TIMEOUT_INVALID",
|
|
367
|
+
`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function subscribesTo(config, event) {
|
|
373
|
+
return (config.events ?? DEFAULT_EVENTS).includes(event);
|
|
374
|
+
}
|
|
375
|
+
var DISCORD_COLORS = {
|
|
376
|
+
bug: 15680580,
|
|
377
|
+
question: 3900150,
|
|
378
|
+
change: 16096779,
|
|
379
|
+
other: 7041664
|
|
380
|
+
};
|
|
381
|
+
var DEFAULT_DISCORD_COLOR = 7041664;
|
|
382
|
+
function deliverySubject(delivery) {
|
|
383
|
+
switch (delivery.event) {
|
|
384
|
+
case "feedback.deleted":
|
|
385
|
+
return delivery.target.kind === "single" ? { feedbackId: delivery.target.id, projectName: delivery.target.projectName } : { feedbackId: "", projectName: delivery.target.projectName };
|
|
386
|
+
case "comment.created":
|
|
387
|
+
return { feedbackId: delivery.comment.feedbackId, projectName: delivery.comment.projectName };
|
|
388
|
+
default:
|
|
389
|
+
return { feedbackId: delivery.feedback.id, projectName: delivery.feedback.projectName };
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
function truncate(text, max = 300) {
|
|
393
|
+
if (text.length <= max) return text;
|
|
394
|
+
return `${text.slice(0, max - 1)}\u2026`;
|
|
395
|
+
}
|
|
396
|
+
function fitEscaped(units, max) {
|
|
397
|
+
const escaped = units.join("");
|
|
398
|
+
if (escaped.length <= max) return escaped;
|
|
399
|
+
let out = "";
|
|
400
|
+
for (const unit of units) {
|
|
401
|
+
if (out.length + unit.length > max - 1) break;
|
|
402
|
+
out += unit;
|
|
403
|
+
}
|
|
404
|
+
return `${out}\u2026`;
|
|
405
|
+
}
|
|
406
|
+
var SLACK_HEADER_MAX = 150;
|
|
407
|
+
var SLACK_TEXT_MAX = 3e3;
|
|
408
|
+
function escapeSlackText(text) {
|
|
409
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
410
|
+
}
|
|
411
|
+
var slackField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
|
|
412
|
+
var SLACK_FROM_HALF = Math.floor((SLACK_TEXT_MAX - 11) / 2);
|
|
413
|
+
var slackProject = (projectName) => `*Project:* ${slackField(projectName, SLACK_TEXT_MAX - 11)}`;
|
|
414
|
+
var slackUrl = (url) => `*URL:* ${slackField(url, SLACK_TEXT_MAX - 7)}`;
|
|
415
|
+
function slackMessage(headline, preview, context) {
|
|
416
|
+
return {
|
|
417
|
+
text: `${escapeSlackText(headline)}: ${preview}`,
|
|
418
|
+
blocks: [
|
|
419
|
+
{
|
|
420
|
+
type: "header",
|
|
421
|
+
text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
type: "section",
|
|
425
|
+
text: { type: "mrkdwn", text: preview }
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
type: "context",
|
|
429
|
+
elements: context.map((text) => ({ type: "mrkdwn", text }))
|
|
430
|
+
}
|
|
431
|
+
]
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
function slackFrom(authorName, authorEmail) {
|
|
435
|
+
const email = authorEmail ? ` (${slackField(authorEmail, SLACK_FROM_HALF)})` : "";
|
|
436
|
+
return `*From:* ${slackField(authorName, SLACK_FROM_HALF)}${email}`;
|
|
437
|
+
}
|
|
438
|
+
var STATUS_VERBS = {
|
|
439
|
+
open: "reopened",
|
|
440
|
+
in_progress: "marked in progress",
|
|
441
|
+
resolved: "resolved",
|
|
442
|
+
wont_fix: "marked won't fix"
|
|
443
|
+
};
|
|
444
|
+
function buildSlackPayload(feedback) {
|
|
445
|
+
return slackMessage(
|
|
446
|
+
`New ${feedback.type} feedback from ${feedback.authorName}`,
|
|
447
|
+
escapeSlackText(truncate(feedback.message)),
|
|
448
|
+
[
|
|
449
|
+
slackProject(feedback.projectName),
|
|
450
|
+
`*Type:* ${feedback.type}`,
|
|
451
|
+
slackUrl(feedback.url),
|
|
452
|
+
`*From:* ${slackField(feedback.authorName, SLACK_FROM_HALF)} (${slackField(feedback.authorEmail, SLACK_FROM_HALF)})`
|
|
453
|
+
]
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
function buildSlackStatusPayload(feedback) {
|
|
457
|
+
return slackMessage(
|
|
458
|
+
`Feedback ${STATUS_VERBS[feedback.status]}: ${feedback.type} from ${feedback.authorName}`,
|
|
459
|
+
escapeSlackText(truncate(feedback.message)),
|
|
460
|
+
[
|
|
461
|
+
slackProject(feedback.projectName),
|
|
462
|
+
`*Status:* ${feedback.status}`,
|
|
463
|
+
slackUrl(feedback.url),
|
|
464
|
+
slackFrom(feedback.authorName, feedback.authorEmail)
|
|
465
|
+
]
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
function buildSlackDeletionPayload(target) {
|
|
469
|
+
if (target.kind === "project") {
|
|
470
|
+
return slackMessage(
|
|
471
|
+
"All feedback deleted",
|
|
472
|
+
`Every feedback of ${slackField(target.projectName, SLACK_TEXT_MAX - 30)} was deleted.`,
|
|
473
|
+
[slackProject(target.projectName)]
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
return slackMessage("Feedback deleted", `Feedback ${slackField(target.id, SLACK_TEXT_MAX - 30)} was deleted.`, [
|
|
477
|
+
slackProject(target.projectName),
|
|
478
|
+
`*ID:* ${slackField(target.id, SLACK_TEXT_MAX - 6)}`
|
|
479
|
+
]);
|
|
480
|
+
}
|
|
481
|
+
function buildSlackCommentPayload(comment) {
|
|
482
|
+
return slackMessage(
|
|
483
|
+
`New ${comment.authorRole} reply from ${comment.authorName}`,
|
|
484
|
+
escapeSlackText(truncate(comment.body)),
|
|
485
|
+
[
|
|
486
|
+
slackProject(comment.projectName),
|
|
487
|
+
`*Feedback:* ${slackField(comment.feedbackId, SLACK_TEXT_MAX - 12)}`,
|
|
488
|
+
slackFrom(comment.authorName, comment.authorEmail)
|
|
489
|
+
]
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
var DISCORD_CONTENT_MAX = 2e3;
|
|
493
|
+
var DISCORD_TITLE_MAX = 256;
|
|
494
|
+
var DISCORD_FIELD_VALUE_MAX = 1024;
|
|
495
|
+
var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
|
|
496
|
+
var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
|
|
497
|
+
var DISCORD_LINK_TRAILER = `.,:;"')]`;
|
|
498
|
+
var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
|
|
499
|
+
var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
500
|
+
var count = (text, char) => text.split(char).length - 1;
|
|
501
|
+
function discordUrlUnits(url) {
|
|
502
|
+
const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
|
|
503
|
+
let end = url.length;
|
|
504
|
+
while (end > 0) {
|
|
505
|
+
const last = url.charAt(end - 1);
|
|
506
|
+
if (!DISCORD_LINK_TRAILER.includes(last)) break;
|
|
507
|
+
if (last === ")" || last === "]") {
|
|
508
|
+
if (open[last] >= 0) break;
|
|
509
|
+
open[last] += 1;
|
|
510
|
+
}
|
|
511
|
+
end -= 1;
|
|
512
|
+
}
|
|
513
|
+
return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
|
|
514
|
+
}
|
|
515
|
+
function escapeDiscordText(text, max) {
|
|
516
|
+
const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
|
|
517
|
+
return fitEscaped(units, max);
|
|
518
|
+
}
|
|
519
|
+
function buildDiscordPayload(feedback) {
|
|
520
|
+
const contentLead = `New **${feedback.type}** feedback from **`;
|
|
521
|
+
const titleLead = `${feedback.type} \u2014 `;
|
|
522
|
+
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
523
|
+
return {
|
|
524
|
+
content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
525
|
+
embeds: [
|
|
526
|
+
{
|
|
527
|
+
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
528
|
+
description: escapeDiscordText(feedback.message, 300),
|
|
529
|
+
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
530
|
+
fields: [
|
|
531
|
+
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
|
|
532
|
+
{
|
|
533
|
+
name: "Author",
|
|
534
|
+
value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
|
|
535
|
+
inline: true
|
|
536
|
+
},
|
|
537
|
+
{ name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
|
|
538
|
+
],
|
|
539
|
+
timestamp: new Date(feedback.createdAt).toISOString()
|
|
540
|
+
}
|
|
541
|
+
],
|
|
542
|
+
allowed_mentions: { parse: [] }
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
var COMMENT_DISCORD_COLOR = 9133302;
|
|
546
|
+
function discordMessage(content, embed) {
|
|
547
|
+
return { content, embeds: [embed], allowed_mentions: { parse: [] } };
|
|
548
|
+
}
|
|
549
|
+
function buildDiscordStatusPayload(feedback) {
|
|
550
|
+
const contentLead = `Feedback **${STATUS_VERBS[feedback.status]}**: ${feedback.type} from **`;
|
|
551
|
+
const titleLead = `${feedback.type} \u2014 `;
|
|
552
|
+
return discordMessage(
|
|
553
|
+
`${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
554
|
+
{
|
|
555
|
+
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
556
|
+
description: escapeDiscordText(feedback.message, 300),
|
|
557
|
+
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
558
|
+
fields: [
|
|
559
|
+
{ name: "Status", value: feedback.status, inline: true },
|
|
560
|
+
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false }
|
|
561
|
+
],
|
|
562
|
+
timestamp: new Date(feedback.updatedAt).toISOString()
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
function buildDiscordDeletionPayload(target) {
|
|
567
|
+
const project = (max) => escapeDiscordText(target.projectName, max);
|
|
568
|
+
const contentLead = target.kind === "project" ? "All feedback deleted from **" : "Feedback deleted from **";
|
|
569
|
+
const fields = [{ name: "Project", value: project(DISCORD_FIELD_VALUE_MAX), inline: true }];
|
|
570
|
+
if (target.kind === "single") {
|
|
571
|
+
fields.push({ name: "ID", value: escapeDiscordText(target.id, DISCORD_FIELD_VALUE_MAX), inline: true });
|
|
572
|
+
}
|
|
573
|
+
return discordMessage(`${contentLead}${project(DISCORD_CONTENT_MAX - contentLead.length - 2)}**`, {
|
|
574
|
+
title: target.kind === "project" ? "All feedback deleted" : "Feedback deleted",
|
|
575
|
+
description: target.kind === "project" ? `Every feedback of ${project(300)} was deleted.` : `Feedback ${escapeDiscordText(target.id, 300)} was deleted.`,
|
|
576
|
+
color: DEFAULT_DISCORD_COLOR,
|
|
577
|
+
fields,
|
|
578
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
function buildDiscordCommentPayload(comment) {
|
|
582
|
+
const contentLead = `New **${comment.authorRole}** reply from **`;
|
|
583
|
+
const titleLead = "Reply \u2014 ";
|
|
584
|
+
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
585
|
+
const author = comment.authorEmail ? `${escapeDiscordText(comment.authorName, authorHalf)} (${escapeDiscordText(comment.authorEmail, authorHalf)})` : escapeDiscordText(comment.authorName, DISCORD_FIELD_VALUE_MAX);
|
|
586
|
+
return discordMessage(
|
|
587
|
+
`${contentLead}${escapeDiscordText(comment.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
588
|
+
{
|
|
589
|
+
title: `${titleLead}${escapeDiscordText(comment.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
590
|
+
description: escapeDiscordText(comment.body, 300),
|
|
591
|
+
color: COMMENT_DISCORD_COLOR,
|
|
592
|
+
fields: [
|
|
593
|
+
{ name: "Feedback", value: escapeDiscordText(comment.feedbackId, DISCORD_FIELD_VALUE_MAX), inline: true },
|
|
594
|
+
{ name: "Author", value: author, inline: true }
|
|
595
|
+
],
|
|
596
|
+
timestamp: new Date(comment.createdAt).toISOString()
|
|
597
|
+
}
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
function buildGenericPayload(feedback) {
|
|
601
|
+
const { clientId: _clientId, comments, ...payload } = feedback;
|
|
602
|
+
if (!comments) return payload;
|
|
603
|
+
return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
|
|
604
|
+
}
|
|
605
|
+
function buildGenericCommentPayload(comment) {
|
|
606
|
+
const { clientId: _clientId, ...payload } = comment;
|
|
607
|
+
return payload;
|
|
608
|
+
}
|
|
609
|
+
function buildDeliveryPayload(type, delivery) {
|
|
610
|
+
switch (delivery.event) {
|
|
611
|
+
case "feedback.created":
|
|
612
|
+
if (type === "slack") return buildSlackPayload(delivery.feedback);
|
|
613
|
+
if (type === "discord") return buildDiscordPayload(delivery.feedback);
|
|
614
|
+
return buildGenericPayload(delivery.feedback);
|
|
615
|
+
case "feedback.status_changed":
|
|
616
|
+
if (type === "slack") return buildSlackStatusPayload(delivery.feedback);
|
|
617
|
+
if (type === "discord") return buildDiscordStatusPayload(delivery.feedback);
|
|
618
|
+
return buildGenericPayload(delivery.feedback);
|
|
619
|
+
case "feedback.deleted":
|
|
620
|
+
if (type === "slack") return buildSlackDeletionPayload(delivery.target);
|
|
621
|
+
if (type === "discord") return buildDiscordDeletionPayload(delivery.target);
|
|
622
|
+
return { ...delivery.target };
|
|
623
|
+
case "comment.created":
|
|
624
|
+
if (type === "slack") return buildSlackCommentPayload(delivery.comment);
|
|
625
|
+
if (type === "discord") return buildDiscordCommentPayload(delivery.comment);
|
|
626
|
+
return buildGenericCommentPayload(delivery.comment);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function dispatchWebhook(config, feedback) {
|
|
630
|
+
return deliver(config, { event: "feedback.created", feedback });
|
|
631
|
+
}
|
|
632
|
+
async function deliver(config, delivery) {
|
|
633
|
+
const problem = timeoutProblem(config.timeoutMs);
|
|
634
|
+
if (problem) return reportError(config, new RangeError(problem), delivery);
|
|
635
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
636
|
+
const controller = new AbortController();
|
|
637
|
+
let timer;
|
|
638
|
+
try {
|
|
639
|
+
const body = JSON.stringify(buildDeliveryPayload(config.type ?? "generic", delivery));
|
|
640
|
+
const custom = config.headers ?? {};
|
|
641
|
+
const customNames = new Set(Object.keys(custom).map((name) => name.toLowerCase()));
|
|
642
|
+
const headers = {
|
|
643
|
+
...customNames.has("content-type") ? {} : { "Content-Type": "application/json" },
|
|
644
|
+
...customNames.has(EVENT_HEADER.toLowerCase()) ? {} : { [EVENT_HEADER]: delivery.event },
|
|
645
|
+
...custom
|
|
646
|
+
};
|
|
647
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
648
|
+
const response = await fetch(config.url, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers,
|
|
651
|
+
body,
|
|
652
|
+
signal: controller.signal
|
|
653
|
+
});
|
|
654
|
+
clearTimeout(timer);
|
|
655
|
+
if (!response.ok) {
|
|
656
|
+
const err = new Error(`Webhook responded with HTTP ${response.status}`);
|
|
657
|
+
await reportError(config, err, delivery);
|
|
658
|
+
}
|
|
659
|
+
} catch (rawError) {
|
|
660
|
+
clearTimeout(timer);
|
|
661
|
+
const err = rawError instanceof Error ? rawError : new Error(String(rawError));
|
|
662
|
+
await reportError(config, err, delivery);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
async function reportError(config, err, delivery) {
|
|
666
|
+
const { feedbackId, projectName } = deliverySubject(delivery);
|
|
667
|
+
const subject = feedbackId ? `feedback ${feedbackId}` : `project ${projectName}`;
|
|
668
|
+
if (config.onError) {
|
|
669
|
+
try {
|
|
670
|
+
await config.onError(err, feedbackId, { event: delivery.event, projectName });
|
|
671
|
+
} catch (callbackErr) {
|
|
672
|
+
warnWithoutUrl(
|
|
673
|
+
config.url,
|
|
674
|
+
`webhook onError() callback threw for ${subject}: ${String(callbackErr)} (original error: ${err.message})`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
warnWithoutUrl(config.url, `webhook to ${webhookOrigin(config.url)} failed for ${subject}: ${err.message}`);
|
|
680
|
+
}
|
|
681
|
+
function webhookOrigin(url) {
|
|
682
|
+
try {
|
|
683
|
+
return new URL(url).origin;
|
|
684
|
+
} catch {
|
|
685
|
+
return "<invalid URL>";
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function warnWithoutUrl(url, message) {
|
|
689
|
+
console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
|
|
690
|
+
}
|
|
691
|
+
async function dispatchWebhooks(configs, feedback) {
|
|
692
|
+
await dispatchDelivery(configs, { event: "feedback.created", feedback });
|
|
693
|
+
}
|
|
694
|
+
async function dispatchDelivery(configs, delivery) {
|
|
695
|
+
const subscribed = configs.filter((config) => subscribesTo(config, delivery.event));
|
|
696
|
+
if (subscribed.length === 0) return;
|
|
697
|
+
await Promise.all(subscribed.map((config) => deliver(config, delivery)));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/notify.ts
|
|
701
|
+
function createNotifier(webhooks, waitUntil, pipeline) {
|
|
702
|
+
return (scope, delivery) => {
|
|
703
|
+
if (!webhooks.some((config) => subscribesTo(config, delivery.event))) return;
|
|
704
|
+
const pending = dispatchDelivery(webhooks, delivery);
|
|
705
|
+
try {
|
|
706
|
+
waitUntil?.(pending);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
const { feedbackId, projectName } = deliverySubject(delivery);
|
|
709
|
+
pipeline.logError(scope, "[siteping] waitUntil failed", {
|
|
710
|
+
error,
|
|
711
|
+
event: delivery.event,
|
|
712
|
+
...feedbackId ? { feedbackId } : {},
|
|
713
|
+
projectName
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
308
719
|
// src/validation.ts
|
|
309
720
|
var zod = __toESM(require("zod"), 1);
|
|
310
721
|
var z2 = "z" in zod ? zod.z : zod;
|
|
@@ -368,9 +779,9 @@ var screenshotRegionSchema = z2.strictObject({
|
|
|
368
779
|
wPct: z2.number().min(0).max(1),
|
|
369
780
|
hPct: z2.number().min(0).max(1)
|
|
370
781
|
});
|
|
371
|
-
var clientIdSchema = z2.string().min(1).max(
|
|
782
|
+
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, _, -)");
|
|
372
783
|
var feedbackCreateSchema = z2.object({
|
|
373
|
-
projectName: z2.string().min(1).max(
|
|
784
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
374
785
|
type: z2.enum(FEEDBACK_TYPES),
|
|
375
786
|
message: z2.string().min(1).max(5e3),
|
|
376
787
|
// Page-scope identifier the widget uses to group feedbacks. Defaults to
|
|
@@ -408,7 +819,7 @@ var feedbackCreateSchema = z2.object({
|
|
|
408
819
|
diagnostics: diagnosticsSchema.nullable().optional()
|
|
409
820
|
});
|
|
410
821
|
var commentCreateSchema = z2.object({
|
|
411
|
-
projectName: z2.string().min(1).max(
|
|
822
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
412
823
|
feedbackId: z2.string().min(1).max(200),
|
|
413
824
|
// Trimmed, so a whitespace-only reply is a 400 rather than an empty bubble.
|
|
414
825
|
body: z2.string().trim().min(1).max(COMMENT_BODY_MAX_LENGTH),
|
|
@@ -422,21 +833,21 @@ var commentCreateSchema = z2.object({
|
|
|
422
833
|
clientId: clientIdSchema
|
|
423
834
|
});
|
|
424
835
|
var commentDeleteSchema = z2.object({
|
|
425
|
-
projectName: z2.string().min(1).max(
|
|
836
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
426
837
|
feedbackId: z2.string().min(1).max(200),
|
|
427
838
|
commentId: z2.string().min(1).max(200)
|
|
428
839
|
});
|
|
429
840
|
var feedbackPatchSchema = z2.object({
|
|
430
841
|
id: z2.string().min(1),
|
|
431
|
-
projectName: z2.string().min(1).max(
|
|
842
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
432
843
|
status: z2.enum(FEEDBACK_STATUSES)
|
|
433
844
|
});
|
|
434
845
|
var feedbackDeleteSchema = z2.union([
|
|
435
|
-
z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(
|
|
436
|
-
z2.object({ projectName: z2.string().min(1).max(
|
|
846
|
+
z2.object({ id: z2.string().min(1), projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH) }),
|
|
847
|
+
z2.object({ projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH), deleteAll: z2.literal(true) })
|
|
437
848
|
]);
|
|
438
849
|
var getQuerySchema = z2.object({
|
|
439
|
-
projectName: z2.string().min(1).max(
|
|
850
|
+
projectName: z2.string().min(1).max(PROJECT_NAME_MAX_LENGTH),
|
|
440
851
|
page: z2.coerce.number().int().min(1).default(1),
|
|
441
852
|
limit: z2.coerce.number().int().min(1).max(100).default(50),
|
|
442
853
|
type: z2.enum(FEEDBACK_TYPES).optional(),
|
|
@@ -465,7 +876,9 @@ function formatValidationErrors(error) {
|
|
|
465
876
|
function createCommentOperation({
|
|
466
877
|
store,
|
|
467
878
|
pipeline,
|
|
468
|
-
|
|
879
|
+
notify,
|
|
880
|
+
beforeComment,
|
|
881
|
+
onCommentCreated
|
|
469
882
|
}) {
|
|
470
883
|
return async (scope, body) => {
|
|
471
884
|
const payload = pipeline.validate(scope, commentCreateSchema, body);
|
|
@@ -478,12 +891,25 @@ function createCommentOperation({
|
|
|
478
891
|
if (store.verifyProjectOwnership && !await store.verifyProjectOwnership(feedbackId, projectName)) {
|
|
479
892
|
return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
480
893
|
}
|
|
481
|
-
const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope) ? "team" : "client";
|
|
482
|
-
const
|
|
894
|
+
const role = authorRole === "team" && await pipeline.canCommentAsTeam(scope, projectName) ? "team" : "client";
|
|
895
|
+
const input = { ...comment, authorRole: role };
|
|
896
|
+
const { comment: stored, created } = store.addCommentIfAbsent ? await store.addCommentIfAbsent(feedbackId, input) : { comment: await store.addComment(feedbackId, input), created: false };
|
|
483
897
|
if (stored.feedbackId !== feedbackId) {
|
|
484
898
|
return pipeline.error(scope, 409, ERROR_MESSAGES.clientIdUsedByAnotherFeedback);
|
|
485
899
|
}
|
|
486
|
-
|
|
900
|
+
if (created) {
|
|
901
|
+
const reply = { ...stored, projectName };
|
|
902
|
+
notify(scope, { event: "comment.created", comment: reply });
|
|
903
|
+
if (onCommentCreated) {
|
|
904
|
+
await pipeline.runHook(
|
|
905
|
+
scope,
|
|
906
|
+
"onCommentCreated",
|
|
907
|
+
{ feedbackId, projectName, commentId: stored.id },
|
|
908
|
+
() => onCommentCreated(reply, scope.context)
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return pipeline.json(scope, await pipeline.presentCreatedComment(scope, stored, projectName), { status: 201 });
|
|
487
913
|
} catch (error) {
|
|
488
914
|
if (isStoreNotFound(error)) return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
489
915
|
if (isStoreLimit(error)) return pipeline.error(scope, 409, ERROR_MESSAGES.tooManyComments);
|
|
@@ -493,209 +919,6 @@ function createCommentOperation({
|
|
|
493
919
|
};
|
|
494
920
|
}
|
|
495
921
|
|
|
496
|
-
// src/webhooks.ts
|
|
497
|
-
var DEFAULT_TIMEOUT_MS = 5e3;
|
|
498
|
-
var TIMER_MAX_DELAY_MS = 2147483647;
|
|
499
|
-
function timeoutProblem(timeoutMs) {
|
|
500
|
-
if (timeoutMs === void 0 || Number.isSafeInteger(timeoutMs) && timeoutMs > 0 && timeoutMs <= TIMER_MAX_DELAY_MS) {
|
|
501
|
-
return null;
|
|
502
|
-
}
|
|
503
|
-
return `timeoutMs must be a positive integer of at most ${TIMER_MAX_DELAY_MS}, got ${timeoutMs}`;
|
|
504
|
-
}
|
|
505
|
-
function checkWebhookTimeouts(configs) {
|
|
506
|
-
for (const config of configs) {
|
|
507
|
-
const problem = timeoutProblem(config.timeoutMs);
|
|
508
|
-
if (problem) {
|
|
509
|
-
throw new Error(`[siteping] createSitepingHandler: webhook to ${webhookOrigin(config.url)}: ${problem}.`);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
var DISCORD_COLORS = {
|
|
514
|
-
bug: 15680580,
|
|
515
|
-
question: 3900150,
|
|
516
|
-
change: 16096779,
|
|
517
|
-
other: 7041664
|
|
518
|
-
};
|
|
519
|
-
var DEFAULT_DISCORD_COLOR = 7041664;
|
|
520
|
-
function truncate(text, max = 300) {
|
|
521
|
-
if (text.length <= max) return text;
|
|
522
|
-
return `${text.slice(0, max - 1)}\u2026`;
|
|
523
|
-
}
|
|
524
|
-
function fitEscaped(units, max) {
|
|
525
|
-
const escaped = units.join("");
|
|
526
|
-
if (escaped.length <= max) return escaped;
|
|
527
|
-
let out = "";
|
|
528
|
-
for (const unit of units) {
|
|
529
|
-
if (out.length + unit.length > max - 1) break;
|
|
530
|
-
out += unit;
|
|
531
|
-
}
|
|
532
|
-
return `${out}\u2026`;
|
|
533
|
-
}
|
|
534
|
-
var SLACK_HEADER_MAX = 150;
|
|
535
|
-
var SLACK_TEXT_MAX = 3e3;
|
|
536
|
-
function escapeSlackText(text) {
|
|
537
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
538
|
-
}
|
|
539
|
-
function buildSlackPayload(feedback) {
|
|
540
|
-
const preview = escapeSlackText(truncate(feedback.message));
|
|
541
|
-
const escapeField = (value, max) => fitEscaped(Array.from(value, escapeSlackText), max);
|
|
542
|
-
const fromHalf = Math.floor((SLACK_TEXT_MAX - 11) / 2);
|
|
543
|
-
const headline = `New ${feedback.type} feedback from ${feedback.authorName}`;
|
|
544
|
-
return {
|
|
545
|
-
text: `${escapeSlackText(headline)}: ${preview}`,
|
|
546
|
-
blocks: [
|
|
547
|
-
{
|
|
548
|
-
type: "header",
|
|
549
|
-
text: { type: "plain_text", text: truncate(headline, SLACK_HEADER_MAX), emoji: true }
|
|
550
|
-
},
|
|
551
|
-
{
|
|
552
|
-
type: "section",
|
|
553
|
-
text: { type: "mrkdwn", text: preview }
|
|
554
|
-
},
|
|
555
|
-
{
|
|
556
|
-
type: "context",
|
|
557
|
-
elements: [
|
|
558
|
-
{ type: "mrkdwn", text: `*Project:* ${escapeField(feedback.projectName, SLACK_TEXT_MAX - 11)}` },
|
|
559
|
-
{ type: "mrkdwn", text: `*Type:* ${feedback.type}` },
|
|
560
|
-
{ type: "mrkdwn", text: `*URL:* ${escapeField(feedback.url, SLACK_TEXT_MAX - 7)}` },
|
|
561
|
-
{
|
|
562
|
-
type: "mrkdwn",
|
|
563
|
-
text: `*From:* ${escapeField(feedback.authorName, fromHalf)} (${escapeField(feedback.authorEmail, fromHalf)})`
|
|
564
|
-
}
|
|
565
|
-
]
|
|
566
|
-
}
|
|
567
|
-
]
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
var DISCORD_CONTENT_MAX = 2e3;
|
|
571
|
-
var DISCORD_TITLE_MAX = 256;
|
|
572
|
-
var DISCORD_FIELD_VALUE_MAX = 1024;
|
|
573
|
-
var DISCORD_MARKDOWN = /[\\*_~`|>#[\]()<]/g;
|
|
574
|
-
var DISCORD_AUTOLINK = /(<https?:\/\/[^\s<>]+>|https?:\/\/[^\s<>]+)/i;
|
|
575
|
-
var DISCORD_LINK_TRAILER = `.,:;"')]`;
|
|
576
|
-
var escapeMarkdownChar = (char) => char.replace(DISCORD_MARKDOWN, "\\$&");
|
|
577
|
-
var encodeLinkChar = (char) => char.replace(/[[\]()]/g, (bracket) => `%${bracket.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
578
|
-
var count = (text, char) => text.split(char).length - 1;
|
|
579
|
-
function discordUrlUnits(url) {
|
|
580
|
-
const open = { ")": count(url, "(") - count(url, ")"), "]": count(url, "[") - count(url, "]") };
|
|
581
|
-
let end = url.length;
|
|
582
|
-
while (end > 0) {
|
|
583
|
-
const last = url.charAt(end - 1);
|
|
584
|
-
if (!DISCORD_LINK_TRAILER.includes(last)) break;
|
|
585
|
-
if (last === ")" || last === "]") {
|
|
586
|
-
if (open[last] >= 0) break;
|
|
587
|
-
open[last] += 1;
|
|
588
|
-
}
|
|
589
|
-
end -= 1;
|
|
590
|
-
}
|
|
591
|
-
return [...Array.from(url.slice(0, end), encodeLinkChar), ...url.slice(end)];
|
|
592
|
-
}
|
|
593
|
-
function escapeDiscordText(text, max) {
|
|
594
|
-
const units = text.split(DISCORD_AUTOLINK).flatMap((part, index) => index % 2 === 1 ? discordUrlUnits(part) : Array.from(part, escapeMarkdownChar));
|
|
595
|
-
return fitEscaped(units, max);
|
|
596
|
-
}
|
|
597
|
-
function buildDiscordPayload(feedback) {
|
|
598
|
-
const contentLead = `New **${feedback.type}** feedback from **`;
|
|
599
|
-
const titleLead = `${feedback.type} \u2014 `;
|
|
600
|
-
const authorHalf = Math.floor((DISCORD_FIELD_VALUE_MAX - 3) / 2);
|
|
601
|
-
return {
|
|
602
|
-
content: `${contentLead}${escapeDiscordText(feedback.authorName, DISCORD_CONTENT_MAX - contentLead.length - 2)}**`,
|
|
603
|
-
embeds: [
|
|
604
|
-
{
|
|
605
|
-
title: `${titleLead}${escapeDiscordText(feedback.projectName, DISCORD_TITLE_MAX - titleLead.length)}`,
|
|
606
|
-
description: escapeDiscordText(feedback.message, 300),
|
|
607
|
-
color: DISCORD_COLORS[feedback.type] ?? DEFAULT_DISCORD_COLOR,
|
|
608
|
-
fields: [
|
|
609
|
-
{ name: "URL", value: escapeDiscordText(feedback.url, DISCORD_FIELD_VALUE_MAX), inline: false },
|
|
610
|
-
{
|
|
611
|
-
name: "Author",
|
|
612
|
-
value: `${escapeDiscordText(feedback.authorName, authorHalf)} (${escapeDiscordText(feedback.authorEmail, authorHalf)})`,
|
|
613
|
-
inline: true
|
|
614
|
-
},
|
|
615
|
-
{ name: "Viewport", value: escapeDiscordText(feedback.viewport, DISCORD_FIELD_VALUE_MAX), inline: true }
|
|
616
|
-
],
|
|
617
|
-
timestamp: new Date(feedback.createdAt).toISOString()
|
|
618
|
-
}
|
|
619
|
-
],
|
|
620
|
-
allowed_mentions: { parse: [] }
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
function buildGenericPayload(feedback) {
|
|
624
|
-
const { clientId: _clientId, comments, ...payload } = feedback;
|
|
625
|
-
if (!comments) return payload;
|
|
626
|
-
return { ...payload, comments: comments.map(({ clientId: _commentClientId, ...comment }) => comment) };
|
|
627
|
-
}
|
|
628
|
-
function buildWebhookPayload(type, feedback) {
|
|
629
|
-
switch (type) {
|
|
630
|
-
case "slack":
|
|
631
|
-
return buildSlackPayload(feedback);
|
|
632
|
-
case "discord":
|
|
633
|
-
return buildDiscordPayload(feedback);
|
|
634
|
-
default:
|
|
635
|
-
return buildGenericPayload(feedback);
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
async function dispatchWebhook(config, feedback) {
|
|
639
|
-
const problem = timeoutProblem(config.timeoutMs);
|
|
640
|
-
if (problem) return reportError(config, new RangeError(problem), feedback.id);
|
|
641
|
-
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
642
|
-
const controller = new AbortController();
|
|
643
|
-
let timer;
|
|
644
|
-
try {
|
|
645
|
-
const body = JSON.stringify(buildWebhookPayload(config.type ?? "generic", feedback));
|
|
646
|
-
const custom = config.headers ?? {};
|
|
647
|
-
const setsContentType = Object.keys(custom).some((name) => name.toLowerCase() === "content-type");
|
|
648
|
-
const headers = setsContentType ? custom : { "Content-Type": "application/json", ...custom };
|
|
649
|
-
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
650
|
-
const response = await fetch(config.url, {
|
|
651
|
-
method: "POST",
|
|
652
|
-
headers,
|
|
653
|
-
body,
|
|
654
|
-
signal: controller.signal
|
|
655
|
-
});
|
|
656
|
-
clearTimeout(timer);
|
|
657
|
-
if (!response.ok) {
|
|
658
|
-
const err = new Error(`Webhook responded with HTTP ${response.status}`);
|
|
659
|
-
await reportError(config, err, feedback.id);
|
|
660
|
-
}
|
|
661
|
-
} catch (rawError) {
|
|
662
|
-
clearTimeout(timer);
|
|
663
|
-
const err = rawError instanceof Error ? rawError : new Error(String(rawError));
|
|
664
|
-
await reportError(config, err, feedback.id);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
async function reportError(config, err, feedbackId) {
|
|
668
|
-
if (config.onError) {
|
|
669
|
-
try {
|
|
670
|
-
await config.onError(err, feedbackId);
|
|
671
|
-
} catch (callbackErr) {
|
|
672
|
-
warnWithoutUrl(
|
|
673
|
-
config.url,
|
|
674
|
-
`webhook onError() callback threw for feedback ${feedbackId}: ${String(callbackErr)} (original error: ${err.message})`
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
return;
|
|
678
|
-
}
|
|
679
|
-
warnWithoutUrl(
|
|
680
|
-
config.url,
|
|
681
|
-
`webhook to ${webhookOrigin(config.url)} failed for feedback ${feedbackId}: ${err.message}`
|
|
682
|
-
);
|
|
683
|
-
}
|
|
684
|
-
function webhookOrigin(url) {
|
|
685
|
-
try {
|
|
686
|
-
return new URL(url).origin;
|
|
687
|
-
} catch {
|
|
688
|
-
return "<invalid URL>";
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
function warnWithoutUrl(url, message) {
|
|
692
|
-
console.warn(`[siteping] ${url ? message.split(url).join(webhookOrigin(url)) : message}`);
|
|
693
|
-
}
|
|
694
|
-
async function dispatchWebhooks(configs, feedback) {
|
|
695
|
-
if (configs.length === 0) return;
|
|
696
|
-
await Promise.all(configs.map((c) => dispatchWebhook(c, feedback)));
|
|
697
|
-
}
|
|
698
|
-
|
|
699
922
|
// src/operations/create-feedback.ts
|
|
700
923
|
var FAILED_TO_CREATE = "[siteping] Failed to create feedback";
|
|
701
924
|
function toCreateInput(data) {
|
|
@@ -720,8 +943,7 @@ function toCreateInput(data) {
|
|
|
720
943
|
function createFeedbackOperation({
|
|
721
944
|
store,
|
|
722
945
|
pipeline,
|
|
723
|
-
|
|
724
|
-
waitUntil,
|
|
946
|
+
notify,
|
|
725
947
|
beforeCreate,
|
|
726
948
|
onCreated
|
|
727
949
|
}) {
|
|
@@ -781,14 +1003,7 @@ function createFeedbackOperation({
|
|
|
781
1003
|
const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
|
|
782
1004
|
try {
|
|
783
1005
|
if (owner && inserted && feedback.projectName === input.projectName) {
|
|
784
|
-
|
|
785
|
-
const delivery = dispatchWebhooks(webhooks, feedback);
|
|
786
|
-
try {
|
|
787
|
-
waitUntil?.(delivery);
|
|
788
|
-
} catch (error) {
|
|
789
|
-
pipeline.logError(scope, "[siteping] waitUntil failed", { error, ...subject });
|
|
790
|
-
}
|
|
791
|
-
}
|
|
1006
|
+
notify(scope, { event: "feedback.created", feedback });
|
|
792
1007
|
if (onCreated) await pipeline.runHook(scope, "onCreated", subject, () => onCreated(feedback, scope.context));
|
|
793
1008
|
}
|
|
794
1009
|
if (feedback.projectName !== input.projectName) {
|
|
@@ -827,6 +1042,7 @@ function deleteCommentOperation({ store, pipeline }) {
|
|
|
827
1042
|
function deleteFeedbackOperation({
|
|
828
1043
|
store,
|
|
829
1044
|
pipeline,
|
|
1045
|
+
notify,
|
|
830
1046
|
onDeleting,
|
|
831
1047
|
onDeleted
|
|
832
1048
|
}) {
|
|
@@ -853,6 +1069,7 @@ function deleteFeedbackOperation({
|
|
|
853
1069
|
}
|
|
854
1070
|
if (target.kind === "project") await store.deleteAllFeedbacks(target.projectName);
|
|
855
1071
|
else await store.deleteFeedback(target.id);
|
|
1072
|
+
notify(scope, { event: "feedback.deleted", target });
|
|
856
1073
|
if (onDeleted) await pipeline.runHook(scope, "onDeleted", { target }, () => onDeleted(target, scope.context));
|
|
857
1074
|
return pipeline.json(scope, { deleted: true });
|
|
858
1075
|
} catch (error) {
|
|
@@ -893,6 +1110,8 @@ function listFeedbacksOperation({ store, pipeline }) {
|
|
|
893
1110
|
{
|
|
894
1111
|
...page,
|
|
895
1112
|
feedbacks,
|
|
1113
|
+
// Lets clients rely on what a protocol version added instead of probing for fields.
|
|
1114
|
+
protocol: SITEPING_PROTOCOL_VERSION,
|
|
896
1115
|
// Lets clients hide their comment composer, and delete buttons, up front instead of meeting a 501.
|
|
897
1116
|
capabilities: {
|
|
898
1117
|
comments: store.addComment !== void 0,
|
|
@@ -913,6 +1132,7 @@ function listFeedbacksOperation({ store, pipeline }) {
|
|
|
913
1132
|
function updateFeedbackOperation({
|
|
914
1133
|
store,
|
|
915
1134
|
pipeline,
|
|
1135
|
+
notify,
|
|
916
1136
|
onUpdated
|
|
917
1137
|
}) {
|
|
918
1138
|
return async (request) => {
|
|
@@ -929,6 +1149,7 @@ function updateFeedbackOperation({
|
|
|
929
1149
|
return pipeline.error(scope, 404, ERROR_MESSAGES.feedbackNotFound);
|
|
930
1150
|
}
|
|
931
1151
|
const feedback = await store.updateFeedback(id, toFeedbackUpdate(status));
|
|
1152
|
+
notify(scope, { event: "feedback.status_changed", feedback });
|
|
932
1153
|
if (onUpdated) {
|
|
933
1154
|
const subject = { feedbackId: feedback.id, projectName: feedback.projectName };
|
|
934
1155
|
await pipeline.runHook(scope, "onUpdated", subject, () => onUpdated(feedback, scope.context));
|
|
@@ -1071,14 +1292,26 @@ function createPipeline({
|
|
|
1071
1292
|
]);
|
|
1072
1293
|
return { canChangeStatus, canDelete, canComment, canDeleteComment };
|
|
1073
1294
|
};
|
|
1074
|
-
const
|
|
1075
|
-
...
|
|
1076
|
-
presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
|
|
1077
|
-
includeEmail,
|
|
1078
|
-
scope.canReadAuthorEmail
|
|
1079
|
-
),
|
|
1080
|
-
permissions: await permissions(scope, feedback)
|
|
1295
|
+
const commentPermissions = async (scope, thread, commentId, canDeleteThread) => ({
|
|
1296
|
+
canDelete: canDeleteThread || await may(scope, "DELETE", { action: "deleteComment", ...thread, commentId })
|
|
1081
1297
|
});
|
|
1298
|
+
const present = async (scope, feedback, echoEmail = false) => {
|
|
1299
|
+
const canReadEmails = await scope.canReadAuthorEmail(feedback.projectName);
|
|
1300
|
+
const wire = toWireFeedback(
|
|
1301
|
+
presentFeedback ? presentFeedback(feedback, scope.context) : feedback,
|
|
1302
|
+
echoEmail || canReadEmails,
|
|
1303
|
+
canReadEmails
|
|
1304
|
+
);
|
|
1305
|
+
const granted = await permissions(scope, feedback);
|
|
1306
|
+
const thread = { projectName: feedback.projectName, feedbackId: feedback.id };
|
|
1307
|
+
const comments = await Promise.all(
|
|
1308
|
+
wire.comments.map(async (comment) => ({
|
|
1309
|
+
...comment,
|
|
1310
|
+
permissions: await commentPermissions(scope, thread, comment.id, granted.canDeleteComment)
|
|
1311
|
+
}))
|
|
1312
|
+
);
|
|
1313
|
+
return { ...wire, comments, permissions: granted };
|
|
1314
|
+
};
|
|
1082
1315
|
return {
|
|
1083
1316
|
json,
|
|
1084
1317
|
error,
|
|
@@ -1114,12 +1347,21 @@ function createPipeline({
|
|
|
1114
1347
|
};
|
|
1115
1348
|
}
|
|
1116
1349
|
if (!outcome.ok) return { ok: false, response: error({ corsHeaders }, outcome.status, outcome.error) };
|
|
1350
|
+
const context = { request, principal: outcome.principal };
|
|
1351
|
+
const emailAccess = /* @__PURE__ */ new Map();
|
|
1117
1352
|
return {
|
|
1118
1353
|
ok: true,
|
|
1119
1354
|
value: {
|
|
1120
|
-
context
|
|
1355
|
+
context,
|
|
1121
1356
|
corsHeaders,
|
|
1122
|
-
canReadAuthorEmail
|
|
1357
|
+
canReadAuthorEmail(projectName) {
|
|
1358
|
+
let answer = emailAccess.get(projectName);
|
|
1359
|
+
if (!answer) {
|
|
1360
|
+
answer = gate.canReadAuthorEmail(context, projectName);
|
|
1361
|
+
emailAccess.set(projectName, answer);
|
|
1362
|
+
}
|
|
1363
|
+
return answer;
|
|
1364
|
+
}
|
|
1123
1365
|
}
|
|
1124
1366
|
};
|
|
1125
1367
|
},
|
|
@@ -1129,9 +1371,16 @@ function createPipeline({
|
|
|
1129
1371
|
const body = await readJson(scope);
|
|
1130
1372
|
return body.ok ? validate(scope, schema, body.value) : body;
|
|
1131
1373
|
},
|
|
1132
|
-
/**
|
|
1374
|
+
/**
|
|
1375
|
+
* `null` when the policy allows the request, its 403 otherwise. An
|
|
1376
|
+
* allowed request also settles its email access for the project now,
|
|
1377
|
+
* before anything is written: a throwing `canReadAuthorEmail` answers
|
|
1378
|
+
* the operation's 500 with nothing stored.
|
|
1379
|
+
*/
|
|
1133
1380
|
async authorize(scope, target) {
|
|
1134
|
-
|
|
1381
|
+
if (!await gate.authorize({ ...scope.context, ...target })) return error(scope, 403, ERROR_MESSAGES.forbidden);
|
|
1382
|
+
await scope.canReadAuthorEmail(target.projectName);
|
|
1383
|
+
return null;
|
|
1135
1384
|
},
|
|
1136
1385
|
/**
|
|
1137
1386
|
* Wire shape of a record answering a POST (fresh or replayed): the
|
|
@@ -1139,15 +1388,29 @@ function createPipeline({
|
|
|
1139
1388
|
* its submitter — who supplied the feedback's, never its thread's.
|
|
1140
1389
|
*/
|
|
1141
1390
|
presentCreated(scope, feedback) {
|
|
1142
|
-
return present(scope, feedback, gate.echoesAuthorEmailOnCreate
|
|
1391
|
+
return present(scope, feedback, gate.echoesAuthorEmailOnCreate);
|
|
1143
1392
|
},
|
|
1144
|
-
/**
|
|
1145
|
-
|
|
1146
|
-
|
|
1393
|
+
/**
|
|
1394
|
+
* Wire shape of a comment answering a POST, in `projectName`'s thread —
|
|
1395
|
+
* the same echo rule as {@link presentCreated}, with its permissions.
|
|
1396
|
+
*/
|
|
1397
|
+
async presentCreatedComment(scope, comment, projectName) {
|
|
1398
|
+
const thread = { projectName, feedbackId: comment.feedbackId };
|
|
1399
|
+
const [canReadEmails, canDeleteThread] = await Promise.all([
|
|
1400
|
+
scope.canReadAuthorEmail(projectName),
|
|
1401
|
+
may(scope, "DELETE", { action: "deleteComment", ...thread })
|
|
1402
|
+
]);
|
|
1403
|
+
return {
|
|
1404
|
+
...toWireComment(comment, gate.echoesAuthorEmailOnCreate || canReadEmails),
|
|
1405
|
+
permissions: await commentPermissions(scope, thread, comment.id, canDeleteThread)
|
|
1406
|
+
};
|
|
1147
1407
|
},
|
|
1148
|
-
/**
|
|
1149
|
-
|
|
1150
|
-
|
|
1408
|
+
/**
|
|
1409
|
+
* Whether this caller's comment in `projectName` keeps the `team` role it
|
|
1410
|
+
* asks for — see `AccessGate.canCommentAsTeam`.
|
|
1411
|
+
*/
|
|
1412
|
+
async canCommentAsTeam(scope, projectName) {
|
|
1413
|
+
return gate.canCommentAsTeam(scope.context, projectName, await scope.canReadAuthorEmail(projectName));
|
|
1151
1414
|
},
|
|
1152
1415
|
/**
|
|
1153
1416
|
* Answer a value the store cannot hold with a 422, which a client does
|
|
@@ -1217,15 +1480,17 @@ function createSitepingHandler(options) {
|
|
|
1217
1480
|
maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
1218
1481
|
} = options;
|
|
1219
1482
|
if (!store) {
|
|
1220
|
-
throw new
|
|
1483
|
+
throw new SitepingConfigError("CONFIG_STORE_MISSING", "[siteping] createSitepingHandler requires a `store`.");
|
|
1221
1484
|
}
|
|
1222
1485
|
if (!Number.isSafeInteger(maxBodyBytes) || maxBodyBytes <= 0) {
|
|
1223
|
-
throw new
|
|
1486
|
+
throw new SitepingConfigError(
|
|
1487
|
+
"CONFIG_MAX_BODY_BYTES_INVALID",
|
|
1224
1488
|
`[siteping] createSitepingHandler: \`maxBodyBytes\` must be a positive integer, got ${maxBodyBytes}.`
|
|
1225
1489
|
);
|
|
1226
1490
|
}
|
|
1227
1491
|
if (options.access?.authorize && !store.verifyProjectOwnership) {
|
|
1228
|
-
throw new
|
|
1492
|
+
throw new SitepingConfigError(
|
|
1493
|
+
"CONFIG_OWNERSHIP_CHECK_MISSING",
|
|
1229
1494
|
"[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."
|
|
1230
1495
|
);
|
|
1231
1496
|
}
|
|
@@ -1233,26 +1498,37 @@ function createSitepingHandler(options) {
|
|
|
1233
1498
|
const logger = customLogger ? safeLogger(customLogger) : consoleLogger;
|
|
1234
1499
|
const pipeline = createPipeline({ gate, allowedOrigins, logger, describeError, presentFeedback, maxBodyBytes });
|
|
1235
1500
|
const webhookList = webhooks ? Array.isArray(webhooks) ? webhooks : [webhooks] : [];
|
|
1236
|
-
|
|
1501
|
+
checkWebhookConfigs(webhookList);
|
|
1502
|
+
if (!store.addCommentIfAbsent && (hooks.onCommentCreated || webhookList.some((config) => subscribesTo(config, "comment.created")))) {
|
|
1503
|
+
throw new Error(
|
|
1504
|
+
"[siteping] createSitepingHandler: `comment.created` webhooks and the `onCommentCreated` hook need a store implementing `addCommentIfAbsent`, which tells a new reply from a retried one."
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
const notify = createNotifier(webhookList, waitUntil, pipeline);
|
|
1237
1508
|
return {
|
|
1238
1509
|
OPTIONS: (request) => preflightResponse(request, allowedOrigins),
|
|
1239
1510
|
POST: routeByBody(
|
|
1240
1511
|
pipeline,
|
|
1241
1512
|
"POST",
|
|
1242
1513
|
"feedbackId",
|
|
1243
|
-
createCommentOperation({
|
|
1514
|
+
createCommentOperation({
|
|
1515
|
+
store,
|
|
1516
|
+
pipeline,
|
|
1517
|
+
notify,
|
|
1518
|
+
beforeComment,
|
|
1519
|
+
onCommentCreated: hooks.onCommentCreated?.bind(hooks)
|
|
1520
|
+
}),
|
|
1244
1521
|
createFeedbackOperation({
|
|
1245
1522
|
store,
|
|
1246
1523
|
pipeline,
|
|
1247
|
-
|
|
1248
|
-
waitUntil,
|
|
1524
|
+
notify,
|
|
1249
1525
|
beforeCreate,
|
|
1250
1526
|
// Bound so hooks written as class methods keep their `this`.
|
|
1251
1527
|
onCreated: hooks.onCreated?.bind(hooks)
|
|
1252
1528
|
})
|
|
1253
1529
|
),
|
|
1254
1530
|
GET: listFeedbacksOperation({ store, pipeline }),
|
|
1255
|
-
PATCH: updateFeedbackOperation({ store, pipeline, onUpdated: hooks.onUpdated?.bind(hooks) }),
|
|
1531
|
+
PATCH: updateFeedbackOperation({ store, pipeline, notify, onUpdated: hooks.onUpdated?.bind(hooks) }),
|
|
1256
1532
|
DELETE: routeByBody(
|
|
1257
1533
|
pipeline,
|
|
1258
1534
|
"DELETE",
|
|
@@ -1261,6 +1537,7 @@ function createSitepingHandler(options) {
|
|
|
1261
1537
|
deleteFeedbackOperation({
|
|
1262
1538
|
store,
|
|
1263
1539
|
pipeline,
|
|
1540
|
+
notify,
|
|
1264
1541
|
onDeleting: hooks.onDeleting?.bind(hooks),
|
|
1265
1542
|
onDeleted: hooks.onDeleted?.bind(hooks)
|
|
1266
1543
|
})
|